Browse Source

Merge branch 'roles-and-permissions' into v3.8.0

Owen Diffey 2 years ago
parent
commit
0357004ed8
38 changed files with 2606 additions and 2291 deletions
  1. 1 1
      backend/logic/actions/activities.js
  2. 19 12
      backend/logic/actions/apis.js
  3. 47 44
      backend/logic/actions/dataRequests.js
  4. 0 52
      backend/logic/actions/hooks/adminRequired.js
  5. 0 7
      backend/logic/actions/hooks/index.js
  6. 0 71
      backend/logic/actions/hooks/ownerRequired.js
  7. 100 98
      backend/logic/actions/media.js
  8. 90 85
      backend/logic/actions/news.js
  9. 399 401
      backend/logic/actions/playlists.js
  10. 204 195
      backend/logic/actions/punishments.js
  11. 93 87
      backend/logic/actions/reports.js
  12. 136 132
      backend/logic/actions/songs.js
  13. 283 241
      backend/logic/actions/stations.js
  14. 208 208
      backend/logic/actions/users.js
  15. 3 3
      backend/logic/actions/utils.js
  16. 351 357
      backend/logic/actions/youtube.js
  17. 11 14
      backend/logic/api.js
  18. 282 0
      backend/logic/hooks/hasPermission.js
  19. 1 1
      backend/logic/hooks/loginRequired.js
  20. 27 100
      backend/logic/stations.js
  21. 13 3
      backend/logic/ws.js
  22. 3 3
      frontend/src/components/MainHeader.vue
  23. 10 12
      frontend/src/components/PlaylistTabBase.vue
  24. 10 13
      frontend/src/components/Queue.vue
  25. 3 2
      frontend/src/components/SongItem.vue
  26. 24 14
      frontend/src/components/StationInfoBox.vue
  27. 13 8
      frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue
  28. 53 36
      frontend/src/components/modals/EditPlaylist/index.vue
  29. 48 30
      frontend/src/components/modals/ManageStation/index.vue
  30. 44 17
      frontend/src/main.ts
  31. 61 8
      frontend/src/pages/Admin/index.vue
  32. 17 9
      frontend/src/pages/Home.vue
  33. 6 3
      frontend/src/pages/Profile/index.vue
  34. 4 7
      frontend/src/pages/Station/Sidebar/index.vue
  35. 25 16
      frontend/src/pages/Station/index.vue
  36. 6 0
      frontend/src/stores/manageStation.ts
  37. 5 0
      frontend/src/stores/station.ts
  38. 6 1
      frontend/src/stores/userAuth.ts

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

@@ -1,6 +1,6 @@
 import async from "async";
 
-import { isLoginRequired } from "./hooks";
+import isLoginRequired from "../hooks/loginRequired";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";

+ 19 - 12
backend/logic/actions/apis.js

@@ -2,7 +2,8 @@ import config from "config";
 import async from "async";
 import axios from "axios";
 
-import { isAdminRequired, isLoginRequired } from "./hooks";
+import isLoginRequired from "../hooks/loginRequired";
+import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -70,7 +71,7 @@ export default {
 	 * @param query - the query
 	 * @param {Function} cb
 	 */
-	searchDiscogs: isAdminRequired(function searchDiscogs(session, query, page, cb) {
+	searchDiscogs: useHasPermission("apis.searchDiscogs", function searchDiscogs(session, query, page, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -187,7 +188,7 @@ export default {
 	 * @param {string} page - the admin room to join
 	 * @param {Function} cb - callback
 	 */
-	joinAdminRoom: isAdminRequired((session, page, cb) => {
+	joinAdminRoom(session, page, cb) {
 		if (
 			page === "songs" ||
 			page === "stations" ||
@@ -201,16 +202,22 @@ export default {
 			page === "youtubeVideos" ||
 			page === "import"
 		) {
-			WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session.socketId }).then(() => {
-				WSModule.runJob("SOCKET_JOIN_ROOM", {
-					socketId: session.socketId,
-					room: `admin.${page}`
-				});
-			});
+			hasPermission(`apis.joinAdminRoom.${page}`, session.userId)
+				.then(() =>
+					WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session.socketId }).then(() => {
+						WSModule.runJob(
+							"SOCKET_JOIN_ROOM",
+							{
+								socketId: session.socketId,
+								room: `admin.${page}`
+							},
+							this
+						).then(() => cb({ status: "success", message: "Successfully joined admin room." }));
+					})
+				)
+				.catch(() => cb({ status: "error", message: "Failed to join admin room." }));
 		}
-
-		cb({ status: "success", message: "Successfully joined admin room." });
-	}),
+	},
 
 	/**
 	 * Leaves all rooms

+ 47 - 44
backend/logic/actions/dataRequests.js

@@ -1,6 +1,6 @@
 import async from "async";
 
-import { isAdminRequired } from "./hooks";
+import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -39,49 +39,52 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "dataRequest",
-							blacklistedProperties: [],
-							specialProperties: {},
-							specialQueries: {}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, response) => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "DATA_REQUESTS_GET_DATA", `Failed to get data from data requests. "${err}"`);
-					return cb({ status: "error", message: err });
+	getData: useHasPermission(
+		"admin.view.users",
+		async function getData(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "dataRequest",
+								blacklistedProperties: [],
+								specialProperties: {},
+								specialQueries: {}
+							},
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "DATA_REQUESTS_GET_DATA", `Failed to get data from data requests. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "DATA_REQUESTS_GET_DATA", `Got data from data requests successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully got data from data requests.",
+						data: response
+					});
 				}
-				this.log("SUCCESS", "DATA_REQUESTS_GET_DATA", `Got data from data requests successfully.`);
-				return cb({
-					status: "success",
-					message: "Successfully got data from data requests.",
-					data: response
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Resolves a data request
@@ -91,7 +94,7 @@ export default {
 	 * @param {boolean} resolved - whether to set to resolved to true or false
 	 * @param {Function} cb - gets called with the result
 	 */
-	resolve: isAdminRequired(async function resolve(session, dataRequestId, resolved, cb) {
+	resolve: useHasPermission("dataRequests.resolve", async function resolve(session, dataRequestId, resolved, cb) {
 		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
 
 		async.waterfall(

+ 0 - 52
backend/logic/actions/hooks/adminRequired.js

@@ -1,52 +0,0 @@
-import async from "async";
-
-// eslint-disable-next-line
-import moduleManager from "../../../index";
-
-const DBModule = moduleManager.modules.db;
-const CacheModule = moduleManager.modules.cache;
-const UtilsModule = moduleManager.modules.utils;
-
-export default destination =>
-	async function adminRequired(session, ...args) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-
-		const cb = args[args.length - 1];
-
-		async.waterfall(
-			[
-				next => {
-					CacheModule.runJob(
-						"HGET",
-						{
-							table: "sessions",
-							key: session.sessionId
-						},
-						this
-					)
-						.then(session => {
-							next(null, session);
-						})
-						.catch(next);
-				},
-				(session, next) => {
-					if (!session || !session.userId) return next("Login required.");
-					return userModel.findOne({ _id: session.userId }, next);
-				},
-				(user, next) => {
-					if (!user) return next("Login required.");
-					if (user.role !== "admin") return next("Insufficient permissions.");
-					return next();
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("INFO", "ADMIN_REQUIRED", `User failed to pass admin required check. "${err}"`);
-					return cb({ status: "error", message: err });
-				}
-				this.log("INFO", "ADMIN_REQUIRED", `User "${session.userId}" passed admin required check.`, false);
-				return destination.apply(this, [session].concat(args));
-			}
-		);
-	};

+ 0 - 7
backend/logic/actions/hooks/index.js

@@ -1,7 +0,0 @@
-import loginRequired from "./loginRequired";
-import adminRequired from "./adminRequired";
-import ownerRequired from "./ownerRequired";
-
-export const isLoginRequired = loginRequired;
-export const isAdminRequired = adminRequired;
-export const isOwnerRequired = ownerRequired;

+ 0 - 71
backend/logic/actions/hooks/ownerRequired.js

@@ -1,71 +0,0 @@
-import async from "async";
-
-// eslint-disable-next-line
-import moduleManager from "../../../index";
-
-const DBModule = moduleManager.modules.db;
-const CacheModule = moduleManager.modules.cache;
-const UtilsModule = moduleManager.modules.utils;
-const StationsModule = moduleManager.modules.stations;
-
-export default destination =>
-	async function ownerRequired(session, stationId, ...args) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-
-		const cb = args[args.length - 1];
-
-		async.waterfall(
-			[
-				next => {
-					CacheModule.runJob(
-						"HGET",
-						{
-							table: "sessions",
-							key: session.sessionId
-						},
-						this
-					)
-						.then(session => next(null, session))
-						.catch(next);
-				},
-				(session, next) => {
-					if (!session || !session.userId) return next("Login required.");
-					return userModel.findOne({ _id: session.userId }, next);
-				},
-				(user, next) => {
-					if (!user) return next("Login required.");
-					if (user.role === "admin") return next(true);
-
-					if (!stationId) return next("Please provide a stationId.");
-
-					return StationsModule.runJob("GET_STATION", { stationId }, this)
-						.then(station => next(null, station))
-						.catch(next);
-				},
-				(station, next) => {
-					if (!station) return next("Station not found.");
-					if (station.type === "community" && station.owner === session.userId) return next(true);
-					return next("Invalid permissions.");
-				}
-			],
-			async err => {
-				if (err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"INFO",
-						"OWNER_REQUIRED",
-						`User failed to pass owner required check for station "${stationId}". "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-				this.log(
-					"INFO",
-					"OWNER_REQUIRED",
-					`User "${session.userId}" passed owner required check for station "${stationId}"`,
-					false
-				);
-
-				return destination.apply(this, [session, stationId].concat(args));
-			}
-		);
-	};

+ 100 - 98
backend/logic/actions/media.js

@@ -1,6 +1,7 @@
 import async from "async";
 
-import { isAdminRequired, isLoginRequired } from "./hooks";
+import isLoginRequired from "../hooks/loginRequired";
+import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -128,55 +129,62 @@ export default {
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param cb
 	 */
-	recalculateAllRatings: isAdminRequired(async function recalculateAllRatings(session, cb) {
-		this.keepLongJob();
-		this.publishProgress({
-			status: "started",
-			title: "Recalculate all ratings",
-			message: "Recalculating all ratings.",
-			id: this.toString()
-		});
-		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
-		await CacheModule.runJob(
-			"PUB",
-			{
-				channel: "longJob.added",
-				value: { jobId: this.toString(), userId: session.userId }
-			},
-			this
-		);
-
-		async.waterfall(
-			[
-				next => {
-					MediaModule.runJob("RECALCULATE_ALL_RATINGS", {}, this)
-						.then(() => {
-							next();
-						})
-						.catch(err => {
-							next(err);
+	recalculateAllRatings: useHasPermission(
+		"media.recalculateAllRatings",
+		async function recalculateAllRatings(session, cb) {
+			this.keepLongJob();
+			this.publishProgress({
+				status: "started",
+				title: "Recalculate all ratings",
+				message: "Recalculating all ratings.",
+				id: this.toString()
+			});
+			await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+			await CacheModule.runJob(
+				"PUB",
+				{
+					channel: "longJob.added",
+					value: { jobId: this.toString(), userId: session.userId }
+				},
+				this
+			);
+
+			async.waterfall(
+				[
+					next => {
+						MediaModule.runJob("RECALCULATE_ALL_RATINGS", {}, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"MEDIA_RECALCULATE_ALL_RATINGS",
+							`Failed to recalculate all ratings. "${err}"`
+						);
+						this.publishProgress({
+							status: "error",
+							message: err
 						});
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "MEDIA_RECALCULATE_ALL_RATINGS", `Failed to recalculate all ratings. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "MEDIA_RECALCULATE_ALL_RATINGS", `Recalculated all ratings successfully.`);
 					this.publishProgress({
-						status: "error",
-						message: err
+						status: "success",
+						message: "Successfully recalculated all ratings."
 					});
-					return cb({ status: "error", message: err });
+					return cb({ status: "success", message: "Successfully recalculated all ratings." });
 				}
-				this.log("SUCCESS", "MEDIA_RECALCULATE_ALL_RATINGS", `Recalculated all ratings successfully.`);
-				this.publishProgress({
-					status: "success",
-					message: "Successfully recalculated all ratings."
-				});
-				return cb({ status: "success", message: "Successfully recalculated all ratings." });
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Like
@@ -841,65 +849,59 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getImportJobs: isAdminRequired(async function getImportJobs(
-		session,
-		page,
-		pageSize,
-		properties,
-		sort,
-		queries,
-		operator,
-		cb
-	) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "importJob",
-							blacklistedProperties: [],
-							specialProperties: {},
-							specialQueries: {}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, response) => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "MEDIA_GET_IMPORT_JOBS", `Failed to get import jobs. "${err}"`);
-					return cb({ status: "error", message: err });
+	getImportJobs: useHasPermission(
+		"admin.view.import",
+		async function getImportJobs(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "importJob",
+								blacklistedProperties: [],
+								specialProperties: {},
+								specialQueries: {}
+							},
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "MEDIA_GET_IMPORT_JOBS", `Failed to get import jobs. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "MEDIA_GET_IMPORT_JOBS", `Fetched import jobs successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully fetched import jobs.",
+						data: response
+					});
 				}
-				this.log("SUCCESS", "MEDIA_GET_IMPORT_JOBS", `Fetched import jobs successfully.`);
-				return cb({
-					status: "success",
-					message: "Successfully fetched import jobs.",
-					data: response
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Remove import jobs
 	 *
 	 * @returns {{status: string, data: object}}
 	 */
-	removeImportJobs: isAdminRequired(function removeImportJobs(session, jobIds, cb) {
+	removeImportJobs: useHasPermission("media.removeImportJobs", function removeImportJobs(session, jobIds, cb) {
 		MediaModule.runJob("REMOVE_IMPORT_JOBS", { jobIds }, this)
 			.then(() => {
 				this.log("SUCCESS", "MEDIA_REMOVE_IMPORT_JOBS", `Removing import jobs was successful.`);

+ 90 - 85
backend/logic/actions/news.js

@@ -1,6 +1,6 @@
 import async from "async";
 
-import { isAdminRequired } from "./hooks";
+import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -69,93 +69,98 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "news",
-							blacklistedProperties: [],
-							specialProperties: {
-								createdBy: [
-									{
-										$addFields: {
-											createdByOID: {
-												$convert: {
-													input: "$createdBy",
-													to: "objectId",
-													onError: "unknown",
-													onNull: "unknown"
+	getData: useHasPermission(
+		"admin.view.news",
+		async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "news",
+								blacklistedProperties: [],
+								specialProperties: {
+									createdBy: [
+										{
+											$addFields: {
+												createdByOID: {
+													$convert: {
+														input: "$createdBy",
+														to: "objectId",
+														onError: "unknown",
+														onNull: "unknown"
+													}
 												}
 											}
-										}
-									},
-									{
-										$lookup: {
-											from: "users",
-											localField: "createdByOID",
-											foreignField: "_id",
-											as: "createdByUser"
-										}
-									},
-									{
-										$unwind: {
-											path: "$createdByUser",
-											preserveNullAndEmptyArrays: true
-										}
-									},
-									{
-										$addFields: {
-											createdByUsername: {
-												$ifNull: ["$createdByUser.username", "unknown"]
+										},
+										{
+											$lookup: {
+												from: "users",
+												localField: "createdByOID",
+												foreignField: "_id",
+												as: "createdByUser"
+											}
+										},
+										{
+											$unwind: {
+												path: "$createdByUser",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										{
+											$addFields: {
+												createdByUsername: {
+													$ifNull: ["$createdByUser.username", "unknown"]
+												}
+											}
+										},
+										{
+											$project: {
+												createdByOID: 0,
+												createdByUser: 0
 											}
 										}
-									},
-									{
-										$project: {
-											createdByOID: 0,
-											createdByUser: 0
-										}
-									}
-								]
+									]
+								},
+								specialQueries: {
+									createdBy: newQuery => ({
+										$or: [newQuery, { createdByUsername: newQuery.createdBy }]
+									})
+								}
 							},
-							specialQueries: {
-								createdBy: newQuery => ({ $or: [newQuery, { createdByUsername: newQuery.createdBy }] })
-							}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "NEWS_GET_DATA", `Failed to get data from news. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "NEWS_GET_DATA", `Got data from news successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully got data from news.",
+						data: response
+					});
 				}
-			],
-			async (err, response) => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "NEWS_GET_DATA", `Failed to get data from news. "${err}"`);
-					return cb({ status: "error", message: err });
-				}
-				this.log("SUCCESS", "NEWS_GET_DATA", `Got data from news successfully.`);
-				return cb({
-					status: "success",
-					message: "Successfully got data from news.",
-					data: response
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Gets all news items that are published
@@ -221,7 +226,7 @@ export default {
 	 * @param {object} data - the object of the news data
 	 * @param {Function} cb - gets called with the result
 	 */
-	create: isAdminRequired(async function create(session, data, cb) {
+	create: useHasPermission("news.create", async function create(session, data, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 		async.waterfall(
 			[
@@ -283,7 +288,7 @@ export default {
 	 * @param {object} newsId - the id of the news item we want to remove
 	 * @param {Function} cb - gets called with the result
 	 */
-	remove: isAdminRequired(async function remove(session, newsId, cb) {
+	remove: useHasPermission("news.remove", async function remove(session, newsId, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 
 		async.waterfall(
@@ -331,7 +336,7 @@ export default {
 	 * @param {string} item.markdown - the markdown that forms the content of the news
 	 * @param {Function} cb - gets called with the result
 	 */
-	update: isAdminRequired(async function update(session, newsId, item, cb) {
+	update: useHasPermission("news.update", async function update(session, newsId, item, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 
 		async.waterfall(

+ 399 - 401
backend/logic/actions/playlists.js

@@ -1,7 +1,8 @@
 import async from "async";
 import config from "config";
 
-import { isAdminRequired, isLoginRequired } from "./hooks";
+import isLoginRequired from "../hooks/loginRequired";
+import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -275,107 +276,112 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "playlist",
-							blacklistedProperties: [],
-							specialProperties: {
-								totalLength: [
-									{
-										$addFields: {
-											totalLength: { $sum: "$songs.duration" }
+	getData: useHasPermission(
+		"admin.view.playlists",
+		async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "playlist",
+								blacklistedProperties: [],
+								specialProperties: {
+									totalLength: [
+										{
+											$addFields: {
+												totalLength: { $sum: "$songs.duration" }
+											}
 										}
-									}
-								],
-								songsCount: [
-									{
-										$addFields: {
-											songsCount: { $size: "$songs" }
+									],
+									songsCount: [
+										{
+											$addFields: {
+												songsCount: { $size: "$songs" }
+											}
 										}
-									}
-								],
-								createdBy: [
-									{
-										$addFields: {
-											createdByOID: {
-												$convert: {
-													input: "$createdBy",
-													to: "objectId",
-													onError: "unknown",
-													onNull: "unknown"
+									],
+									createdBy: [
+										{
+											$addFields: {
+												createdByOID: {
+													$convert: {
+														input: "$createdBy",
+														to: "objectId",
+														onError: "unknown",
+														onNull: "unknown"
+													}
 												}
 											}
-										}
-									},
-									{
-										$lookup: {
-											from: "users",
-											localField: "createdByOID",
-											foreignField: "_id",
-											as: "createdByUser"
-										}
-									},
-									{
-										$unwind: {
-											path: "$createdByUser",
-											preserveNullAndEmptyArrays: true
-										}
-									},
-									{
-										$addFields: {
-											createdByUsername: {
-												$cond: [
-													{ $eq: ["$createdBy", "Musare"] },
-													"Musare",
-													{ $ifNull: ["$createdByUser.username", "unknown"] }
-												]
+										},
+										{
+											$lookup: {
+												from: "users",
+												localField: "createdByOID",
+												foreignField: "_id",
+												as: "createdByUser"
+											}
+										},
+										{
+											$unwind: {
+												path: "$createdByUser",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										{
+											$addFields: {
+												createdByUsername: {
+													$cond: [
+														{ $eq: ["$createdBy", "Musare"] },
+														"Musare",
+														{ $ifNull: ["$createdByUser.username", "unknown"] }
+													]
+												}
+											}
+										},
+										{
+											$project: {
+												createdByOID: 0,
+												createdByUser: 0
 											}
 										}
-									},
-									{
-										$project: {
-											createdByOID: 0,
-											createdByUser: 0
-										}
-									}
-								]
+									]
+								},
+								specialQueries: {
+									createdBy: newQuery => ({
+										$or: [newQuery, { createdByUsername: newQuery.createdBy }]
+									})
+								}
 							},
-							specialQueries: {
-								createdBy: newQuery => ({ $or: [newQuery, { createdByUsername: newQuery.createdBy }] })
-							}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, response) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "PLAYLISTS_GET_DATA", `Failed to get data from playlists. "${err}"`);
-					return cb({ status: "error", message: err });
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "PLAYLISTS_GET_DATA", `Failed to get data from playlists. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "PLAYLISTS_GET_DATA", `Got data from playlists successfully.`);
+					return cb({ status: "success", message: "Successfully got data from playlists.", data: response });
 				}
-				this.log("SUCCESS", "PLAYLISTS_GET_DATA", `Got data from playlists successfully.`);
-				return cb({ status: "success", message: "Successfully got data from playlists.", data: response });
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Searches through all playlists that can be included in a community station
@@ -431,7 +437,7 @@ export default {
 	 * @param {string} query - the page
 	 * @param {Function} cb - gets called with the result
 	 */
-	searchOfficial: isAdminRequired(async function searchOfficial(session, query, page, cb) {
+	searchOfficial: useHasPermission("playlists.get", async function searchOfficial(session, query, page, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -811,18 +817,10 @@ export default {
 
 				(playlist, next) => {
 					if (!playlist) return next("Playlist not found");
-					if (playlist.privacy !== "public" && playlist.createdBy !== session.userId) {
-						if (session)
-							// check if user requested to get a playlist is an admin
-							return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
-								userModel.findOne({ _id: session.userId }, (err, user) => {
-									if (user && user.role === "admin") return next(null, playlist);
-									return next("User unauthorised to view playlist.");
-								});
-							});
-						return next("User unauthorised to view playlist.");
-					}
-
+					if (playlist.privacy !== "public" && playlist.createdBy !== session.userId)
+						return hasPermission("playlists.get", session)
+							.then(() => next(null, playlist))
+							.catch(() => next("User unauthorised to view playlist."));
 					return next(null, playlist);
 				}
 			],
@@ -870,18 +868,10 @@ export default {
 
 				(playlist, next) => {
 					if (!playlist) return next("Playlist not found");
-					if (playlist.privacy !== "public" && playlist.createdBy !== session.userId) {
-						if (session)
-							// check if user requested to get a playlist is an admin
-							return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
-								userModel.findOne({ _id: session.userId }, (err, user) => {
-									if (user && user.role === "admin") return next(null, playlist);
-									return next("User unauthorised to view playlist.");
-								});
-							});
-						return next("User unauthorised to view playlist.");
-					}
-
+					if (playlist.privacy !== "public")
+						return hasPermission("stations.view", session, stationId)
+							.then(() => next(null, playlist))
+							.catch(() => next("User unauthorised to view playlist."));
 					return next(null, playlist);
 				}
 			],
@@ -1007,14 +997,11 @@ export default {
 				next => {
 					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
 						.then(playlist => {
-							if (!playlist || playlist.createdBy !== session.userId) {
-								return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
-									userModel.findOne({ _id: session.userId }, (err, user) => {
-										if (user && user.role === "admin") return next();
-										return next("Something went wrong when trying to get the playlist");
-									});
-								});
-							}
+							if (!playlist) return next("Playlist not found.");
+							if (playlist.createdBy !== session.userId)
+								return hasPermission("playlists.songs.reposition", session)
+									.then(() => next())
+									.catch(() => next("Invalid permissions."));
 							return next();
 						})
 						.catch(next);
@@ -1098,14 +1085,12 @@ export default {
 				next => {
 					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
 						.then(playlist => {
-							if (!playlist || playlist.createdBy !== session.userId) {
-								DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
-									userModel.findOne({ _id: session.userId }, (err, user) => {
-										if (user && user.role === "admin") return next(null, playlist);
-										return next("Something went wrong when trying to get the playlist");
-									});
-								});
-							} else next(null, playlist);
+							if (!playlist) return next("Playlist not found.");
+							if (playlist.createdBy !== session.userId)
+								return hasPermission("playlists.songs.add", session)
+									.then(() => next(null, playlist))
+									.catch(() => next("Invalid permissions."));
+							return next(null, playlist);
 						})
 						.catch(next);
 				},
@@ -1367,15 +1352,11 @@ export default {
 
 				(playlist, next) => {
 					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 4)` });
-					if (!playlist || playlist.createdBy !== session.userId) {
-						return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
-							userModel.findOne({ _id: session.userId }, (err, user) => {
-								if (user && user.role === "admin") return next(null, playlist);
-								return next("Something went wrong when trying to get the playlist");
-							});
-						});
-					}
-
+					if (!playlist) return next("Playlist not found.");
+					if (playlist.createdBy !== session.userId)
+						return hasPermission("playlists.songs.add", session)
+							.then(() => next(null, playlist))
+							.catch(() => next("Invalid permissions."));
 					return next(null, playlist);
 				}
 			],
@@ -1450,14 +1431,11 @@ export default {
 				next => {
 					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
 						.then(playlist => {
-							if (!playlist || playlist.createdBy !== session.userId) {
-								return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
-									userModel.findOne({ _id: session.userId }, (err, user) => {
-										if (user && user.role === "admin") return next(null, playlist);
-										return next("Something went wrong when trying to get the playlist");
-									});
-								});
-							}
+							if (!playlist) return next("Playlist not found.");
+							if (playlist.createdBy !== session.userId)
+								return hasPermission("playlists.songs.remove", session)
+									.then(() => next(null, playlist))
+									.catch(() => next("Invalid permissions."));
 							return next(null, playlist);
 						})
 						.catch(next);
@@ -1769,7 +1747,7 @@ export default {
 	 * @param {string} playlistId - the id of the playlist we are removing
 	 * @param {Function} cb - gets called with the result
 	 */
-	removeAdmin: isAdminRequired(async function removeAdmin(session, playlistId, cb) {
+	removeAdmin: useHasPermission("playlists.removeAdmin", async function removeAdmin(session, playlistId, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 		async.waterfall(
@@ -1923,66 +1901,74 @@ export default {
 	 * @param {string} privacy - what the new privacy of the playlist should be e.g. public
 	 * @param {Function} cb - gets called with the result
 	 */
-	updatePrivacyAdmin: isAdminRequired(async function updatePrivacyAdmin(session, playlistId, privacy, cb) {
-		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
-
-		async.waterfall(
-			[
-				next => {
-					playlistModel.updateOne({ _id: playlistId }, { $set: { privacy } }, { runValidators: true }, next);
-				},
+	updatePrivacyAdmin: useHasPermission(
+		"playlists.update.privacy",
+		async function updatePrivacyAdmin(session, playlistId, privacy, cb) {
+			const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+			async.waterfall(
+				[
+					next => {
+						playlistModel.updateOne(
+							{ _id: playlistId },
+							{ $set: { privacy } },
+							{ runValidators: true },
+							next
+						);
+					},
+
+					(res, next) => {
+						if (res.n === 0) next("No playlist found with that id.");
+						else if (res.nModified === 0) next(`Nothing changed, the playlist was already ${privacy}.`);
+						else {
+							PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+								.then(playlist => next(null, playlist))
+								.catch(next);
+						}
+					}
+				],
+				async (err, playlist) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+						this.log(
+							"ERROR",
+							"PLAYLIST_UPDATE_PRIVACY_ADMIN",
+							`Updating privacy to "${privacy}" for playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+						);
 
-				(res, next) => {
-					if (res.n === 0) next("No playlist found with that id.");
-					else if (res.nModified === 0) next(`Nothing changed, the playlist was already ${privacy}.`);
-					else {
-						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-							.then(playlist => next(null, playlist))
-							.catch(next);
+						return cb({ status: "error", message: err });
 					}
-				}
-			],
-			async (err, playlist) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
 					this.log(
-						"ERROR",
-						"PLAYLIST_UPDATE_PRIVACY_ADMIN",
-						`Updating privacy to "${privacy}" for playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+						"SUCCESS",
+						"PLAYLIST_UPDATE_PRIVACY_ADMIn",
+						`Successfully updated privacy to "${privacy}" for playlist "${playlistId}" for user "${session.userId}".`
 					);
 
-					return cb({ status: "error", message: err });
-				}
-
-				this.log(
-					"SUCCESS",
-					"PLAYLIST_UPDATE_PRIVACY_ADMIn",
-					`Successfully updated privacy to "${privacy}" for playlist "${playlistId}" for user "${session.userId}".`
-				);
+					if (playlist.type === "user") {
+						CacheModule.runJob("PUB", {
+							channel: "playlist.updatePrivacy",
+							value: {
+								userId: playlist.createdBy,
+								playlist
+							}
+						});
+					}
 
-				if (playlist.type === "user") {
 					CacheModule.runJob("PUB", {
-						channel: "playlist.updatePrivacy",
-						value: {
-							userId: playlist.createdBy,
-							playlist
-						}
+						channel: "playlist.updated",
+						value: { playlistId }
 					});
-				}
 
-				CacheModule.runJob("PUB", {
-					channel: "playlist.updated",
-					value: { playlistId }
-				});
-
-				return cb({
-					status: "success",
-					message: "Playlist has been successfully updated"
-				});
-			}
-		);
-	}),
+					return cb({
+						status: "success",
+						message: "Playlist has been successfully updated"
+					});
+				}
+			);
+		}
+	),
 
 	/**
 	 * Deletes all orphaned station playlists
@@ -1990,7 +1976,7 @@ export default {
 	 * @param {object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 */
-	deleteOrphanedStationPlaylists: isAdminRequired(async function index(session, cb) {
+	deleteOrphanedStationPlaylists: useHasPermission("playlists.deleteOrphaned", async function index(session, cb) {
 		this.keepLongJob();
 		this.publishProgress({
 			status: "started",
@@ -2050,7 +2036,7 @@ export default {
 	 * @param {object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 */
-	deleteOrphanedGenrePlaylists: isAdminRequired(async function index(session, cb) {
+	deleteOrphanedGenrePlaylists: useHasPermission("playlists.deleteOrphaned", async function index(session, cb) {
 		this.keepLongJob();
 		this.publishProgress({
 			status: "started",
@@ -2110,59 +2096,62 @@ export default {
 	 * @param {object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 */
-	requestOrphanedPlaylistSongs: isAdminRequired(async function index(session, cb) {
-		this.keepLongJob();
-		this.publishProgress({
-			status: "started",
-			title: "Request orphaned playlist songs",
-			message: "Requesting orphaned playlist songs.",
-			id: this.toString()
-		});
-		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
-		await CacheModule.runJob(
-			"PUB",
-			{
-				channel: "longJob.added",
-				value: { jobId: this.toString(), userId: session.userId }
-			},
-			this
-		);
-
-		async.waterfall(
-			[
-				next => {
-					SongsModule.runJob("REQUEST_ORPHANED_PLAYLIST_SONGS", {}, this)
-						.then(() => next())
-						.catch(next);
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+	requestOrphanedPlaylistSongs: useHasPermission(
+		"playlists.requestOrphanedPlaylistSongs",
+		async function index(session, cb) {
+			this.keepLongJob();
+			this.publishProgress({
+				status: "started",
+				title: "Request orphaned playlist songs",
+				message: "Requesting orphaned playlist songs.",
+				id: this.toString()
+			});
+			await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+			await CacheModule.runJob(
+				"PUB",
+				{
+					channel: "longJob.added",
+					value: { jobId: this.toString(), userId: session.userId }
+				},
+				this
+			);
+
+			async.waterfall(
+				[
+					next => {
+						SongsModule.runJob("REQUEST_ORPHANED_PLAYLIST_SONGS", {}, this)
+							.then(() => next())
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"REQUEST_ORPHANED_PLAYLIST_SONGS",
+							`Requesting orphaned playlist songs failed. "${err}"`
+						);
+						this.publishProgress({
+							status: "error",
+							message: err
+						});
+						return cb({ status: "error", message: err });
+					}
 					this.log(
-						"ERROR",
+						"SUCCESS",
 						"REQUEST_ORPHANED_PLAYLIST_SONGS",
-						`Requesting orphaned playlist songs failed. "${err}"`
+						"Requesting orphaned playlist songs was successful."
 					);
 					this.publishProgress({
-						status: "error",
-						message: err
+						status: "success",
+						message: "Successfully requested orphaned playlist songs."
 					});
-					return cb({ status: "error", message: err });
+					return cb({ status: "success", message: "Successfully requested orphaned playlist songs." });
 				}
-				this.log(
-					"SUCCESS",
-					"REQUEST_ORPHANED_PLAYLIST_SONGS",
-					"Requesting orphaned playlist songs was successful."
-				);
-				this.publishProgress({
-					status: "success",
-					message: "Successfully requested orphaned playlist songs."
-				});
-				return cb({ status: "success", message: "Successfully requested orphaned playlist songs." });
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Clears and refills a station playlist
@@ -2171,48 +2160,51 @@ export default {
 	 * @param {string} playlistId - the id of the playlist we are clearing and refilling
 	 * @param {Function} cb - gets called with the result
 	 */
-	clearAndRefillStationPlaylist: isAdminRequired(async function index(session, playlistId, cb) {
-		async.waterfall(
-			[
-				next => {
-					if (!playlistId) next("Please specify a playlist id");
-					else {
-						PlaylistsModule.runJob("CLEAR_AND_REFILL_STATION_PLAYLIST", { playlistId }, this)
-							.then(() => {
-								next();
-							})
-							.catch(err => {
-								next(err);
-							});
+	clearAndRefillStationPlaylist: useHasPermission(
+		"playlists.clearAndRefill",
+		async function index(session, playlistId, cb) {
+			async.waterfall(
+				[
+					next => {
+						if (!playlistId) next("Please specify a playlist id");
+						else {
+							PlaylistsModule.runJob("CLEAR_AND_REFILL_STATION_PLAYLIST", { playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						}
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+						this.log(
+							"ERROR",
+							"PLAYLIST_CLEAR_AND_REFILL_STATION_PLAYLIST",
+							`Clearing and refilling station playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+						);
+
+						return cb({ status: "error", message: err });
 					}
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
 					this.log(
-						"ERROR",
+						"SUCCESS",
 						"PLAYLIST_CLEAR_AND_REFILL_STATION_PLAYLIST",
-						`Clearing and refilling station playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+						`Successfully cleared and refilled station playlist "${playlistId}" for user "${session.userId}".`
 					);
 
-					return cb({ status: "error", message: err });
+					return cb({
+						status: "success",
+						message: "Playlist has been successfully cleared and refilled"
+					});
 				}
-
-				this.log(
-					"SUCCESS",
-					"PLAYLIST_CLEAR_AND_REFILL_STATION_PLAYLIST",
-					`Successfully cleared and refilled station playlist "${playlistId}" for user "${session.userId}".`
-				);
-
-				return cb({
-					status: "success",
-					message: "Playlist has been successfully cleared and refilled"
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Clears and refills a genre playlist
@@ -2221,48 +2213,51 @@ export default {
 	 * @param {string} playlistId - the id of the playlist we are clearing and refilling
 	 * @param {Function} cb - gets called with the result
 	 */
-	clearAndRefillGenrePlaylist: isAdminRequired(async function index(session, playlistId, cb) {
-		async.waterfall(
-			[
-				next => {
-					if (!playlistId) next("Please specify a playlist id");
-					else {
-						PlaylistsModule.runJob("CLEAR_AND_REFILL_GENRE_PLAYLIST", { playlistId }, this)
-							.then(() => {
-								next();
-							})
-							.catch(err => {
-								next(err);
-							});
+	clearAndRefillGenrePlaylist: useHasPermission(
+		"playlists.clearAndRefill",
+		async function index(session, playlistId, cb) {
+			async.waterfall(
+				[
+					next => {
+						if (!playlistId) next("Please specify a playlist id");
+						else {
+							PlaylistsModule.runJob("CLEAR_AND_REFILL_GENRE_PLAYLIST", { playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						}
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+						this.log(
+							"ERROR",
+							"PLAYLIST_CLEAR_AND_REFILL_GENRE_PLAYLIST",
+							`Clearing and refilling genre playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+						);
+
+						return cb({ status: "error", message: err });
 					}
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
 					this.log(
-						"ERROR",
+						"SUCCESS",
 						"PLAYLIST_CLEAR_AND_REFILL_GENRE_PLAYLIST",
-						`Clearing and refilling genre playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+						`Successfully cleared and refilled genre playlist "${playlistId}" for user "${session.userId}".`
 					);
 
-					return cb({ status: "error", message: err });
+					return cb({
+						status: "success",
+						message: "Playlist has been successfully cleared and refilled"
+					});
 				}
-
-				this.log(
-					"SUCCESS",
-					"PLAYLIST_CLEAR_AND_REFILL_GENRE_PLAYLIST",
-					`Successfully cleared and refilled genre playlist "${playlistId}" for user "${session.userId}".`
-				);
-
-				return cb({
-					status: "success",
-					message: "Playlist has been successfully cleared and refilled"
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Clears and refills all station playlists
@@ -2270,93 +2265,96 @@ export default {
 	 * @param {object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 */
-	clearAndRefillAllStationPlaylists: isAdminRequired(async function index(session, cb) {
-		this.keepLongJob();
-		this.publishProgress({
-			status: "started",
-			title: "Clear and refill all station playlists",
-			message: "Clearing and refilling all station playlists.",
-			id: this.toString()
-		});
-		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
-		await CacheModule.runJob(
-			"PUB",
-			{
-				channel: "longJob.added",
-				value: { jobId: this.toString(), userId: session.userId }
-			},
-			this
-		);
-
-		async.waterfall(
-			[
-				next => {
-					PlaylistsModule.runJob("GET_ALL_STATION_PLAYLISTS", {}, this)
-						.then(response => {
-							next(null, response.playlists);
-						})
-						.catch(err => {
-							next(err);
-						});
+	clearAndRefillAllStationPlaylists: useHasPermission(
+		"playlists.clearAndRefillAll",
+		async function index(session, cb) {
+			this.keepLongJob();
+			this.publishProgress({
+				status: "started",
+				title: "Clear and refill all station playlists",
+				message: "Clearing and refilling all station playlists.",
+				id: this.toString()
+			});
+			await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+			await CacheModule.runJob(
+				"PUB",
+				{
+					channel: "longJob.added",
+					value: { jobId: this.toString(), userId: session.userId }
 				},
-
-				(playlists, next) => {
-					async.eachLimit(
-						playlists,
-						1,
-						(playlist, next) => {
-							this.publishProgress({
-								status: "update",
-								message: `Clearing and refilling "${playlist._id}"`
+				this
+			);
+
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_ALL_STATION_PLAYLISTS", {}, this)
+							.then(response => {
+								next(null, response.playlists);
+							})
+							.catch(err => {
+								next(err);
 							});
-							PlaylistsModule.runJob(
-								"CLEAR_AND_REFILL_STATION_PLAYLIST",
-								{ playlistId: playlist._id },
-								this
-							)
-								.then(() => {
-									next();
-								})
-								.catch(err => {
-									next(err);
+					},
+
+					(playlists, next) => {
+						async.eachLimit(
+							playlists,
+							1,
+							(playlist, next) => {
+								this.publishProgress({
+									status: "update",
+									message: `Clearing and refilling "${playlist._id}"`
 								});
-						},
-						next
-					);
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+								PlaylistsModule.runJob(
+									"CLEAR_AND_REFILL_STATION_PLAYLIST",
+									{ playlistId: playlist._id },
+									this
+								)
+									.then(() => {
+										next();
+									})
+									.catch(err => {
+										next(err);
+									});
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+						this.log(
+							"ERROR",
+							"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
+							`Clearing and refilling all station playlists failed for user "${session.userId}". "${err}"`
+						);
+						this.publishProgress({
+							status: "error",
+							message: err
+						});
+						return cb({ status: "error", message: err });
+					}
 
 					this.log(
-						"ERROR",
+						"SUCCESS",
 						"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
-						`Clearing and refilling all station playlists failed for user "${session.userId}". "${err}"`
+						`Successfully cleared and refilled all station playlists for user "${session.userId}".`
 					);
 					this.publishProgress({
-						status: "error",
-						message: err
+						status: "success",
+						message: "Playlists have been successfully cleared and refilled."
+					});
+					return cb({
+						status: "success",
+						message: "Playlists have been successfully cleared and refilled"
 					});
-					return cb({ status: "error", message: err });
 				}
-
-				this.log(
-					"SUCCESS",
-					"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
-					`Successfully cleared and refilled all station playlists for user "${session.userId}".`
-				);
-				this.publishProgress({
-					status: "success",
-					message: "Playlists have been successfully cleared and refilled."
-				});
-				return cb({
-					status: "success",
-					message: "Playlists have been successfully cleared and refilled"
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Clears and refills all genre playlists
@@ -2364,7 +2362,7 @@ export default {
 	 * @param {object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 */
-	clearAndRefillAllGenrePlaylists: isAdminRequired(async function index(session, cb) {
+	clearAndRefillAllGenrePlaylists: useHasPermission("playlists.clearAndRefillAll", async function index(session, cb) {
 		this.keepLongJob();
 		this.publishProgress({
 			status: "started",
@@ -2458,7 +2456,7 @@ export default {
 	 * @param {object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 */
-	createMissingGenrePlaylists: isAdminRequired(async function index(session, cb) {
+	createMissingGenrePlaylists: useHasPermission("playlists.createMissing", async function index(session, cb) {
 		this.keepLongJob();
 		this.publishProgress({
 			status: "started",

+ 204 - 195
backend/logic/actions/punishments.js

@@ -1,6 +1,6 @@
 import async from "async";
 
-import { isAdminRequired } from "./hooks";
+import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -40,160 +40,163 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "punishment",
-							blacklistedProperties: [],
-							specialProperties: {
-								status: [
-									{
-										$addFields: {
-											status: {
-												$cond: [
-													{ $eq: ["$active", true] },
-													{
-														$cond: [
-															{ $gt: [new Date(), "$expiresAt"] },
-															"Inactive",
-															"Active"
-														]
-													},
-													"Inactive"
-												]
+	getData: useHasPermission(
+		"admin.view.punishments",
+		async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "punishment",
+								blacklistedProperties: [],
+								specialProperties: {
+									status: [
+										{
+											$addFields: {
+												status: {
+													$cond: [
+														{ $eq: ["$active", true] },
+														{
+															$cond: [
+																{ $gt: [new Date(), "$expiresAt"] },
+																"Inactive",
+																"Active"
+															]
+														},
+														"Inactive"
+													]
+												}
 											}
 										}
-									}
-								],
-								value: [
-									{
-										$addFields: {
-											valueOID: {
-												$convert: {
-													input: "$value",
-													to: "objectId",
-													onError: "unknown",
-													onNull: "unknown"
+									],
+									value: [
+										{
+											$addFields: {
+												valueOID: {
+													$convert: {
+														input: "$value",
+														to: "objectId",
+														onError: "unknown",
+														onNull: "unknown"
+													}
 												}
 											}
-										}
-									},
-									{
-										$lookup: {
-											from: "users",
-											localField: "valueOID",
-											foreignField: "_id",
-											as: "valueUser"
-										}
-									},
-									{
-										$unwind: {
-											path: "$valueUser",
-											preserveNullAndEmptyArrays: true
-										}
-									},
-									{
-										$addFields: {
-											valueUsername: {
-												$cond: [
-													{ $eq: ["$type", "banUserId"] },
-													{ $ifNull: ["$valueUser.username", "unknown"] },
-													null
-												]
+										},
+										{
+											$lookup: {
+												from: "users",
+												localField: "valueOID",
+												foreignField: "_id",
+												as: "valueUser"
 											}
-										}
-									},
-									{
-										$project: {
-											valueOID: 0,
-											valueUser: 0
-										}
-									}
-								],
-								punishedBy: [
-									{
-										$addFields: {
-											punishedByOID: {
-												$convert: {
-													input: "$punishedBy",
-													to: "objectId",
-													onError: "unknown",
-													onNull: "unknown"
+										},
+										{
+											$unwind: {
+												path: "$valueUser",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										{
+											$addFields: {
+												valueUsername: {
+													$cond: [
+														{ $eq: ["$type", "banUserId"] },
+														{ $ifNull: ["$valueUser.username", "unknown"] },
+														null
+													]
 												}
 											}
-										}
-									},
-									{
-										$lookup: {
-											from: "users",
-											localField: "punishedByOID",
-											foreignField: "_id",
-											as: "punishedByUser"
-										}
-									},
-									{
-										$unwind: {
-											path: "$punishedByUser",
-											preserveNullAndEmptyArrays: true
-										}
-									},
-									{
-										$addFields: {
-											punishedByUsername: {
-												$ifNull: ["$punishedByUser.username", "unknown"]
+										},
+										{
+											$project: {
+												valueOID: 0,
+												valueUser: 0
 											}
 										}
-									},
-									{
-										$project: {
-											punishedByOID: 0,
-											punishedByUser: 0
+									],
+									punishedBy: [
+										{
+											$addFields: {
+												punishedByOID: {
+													$convert: {
+														input: "$punishedBy",
+														to: "objectId",
+														onError: "unknown",
+														onNull: "unknown"
+													}
+												}
+											}
+										},
+										{
+											$lookup: {
+												from: "users",
+												localField: "punishedByOID",
+												foreignField: "_id",
+												as: "punishedByUser"
+											}
+										},
+										{
+											$unwind: {
+												path: "$punishedByUser",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										{
+											$addFields: {
+												punishedByUsername: {
+													$ifNull: ["$punishedByUser.username", "unknown"]
+												}
+											}
+										},
+										{
+											$project: {
+												punishedByOID: 0,
+												punishedByUser: 0
+											}
 										}
-									}
-								]
+									]
+								},
+								specialQueries: {
+									value: newQuery => ({ $or: [newQuery, { valueUsername: newQuery.value }] }),
+									punishedBy: newQuery => ({
+										$or: [newQuery, { punishedByUsername: newQuery.punishedBy }]
+									})
+								}
 							},
-							specialQueries: {
-								value: newQuery => ({ $or: [newQuery, { valueUsername: newQuery.value }] }),
-								punishedBy: newQuery => ({
-									$or: [newQuery, { punishedByUsername: newQuery.punishedBy }]
-								})
-							}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, response) => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "PUNISHMENTS_GET_DATA", `Failed to get data from punishments. "${err}"`);
-					return cb({ status: "error", message: err });
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "PUNISHMENTS_GET_DATA", `Failed to get data from punishments. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "PUNISHMENTS_GET_DATA", `Got data from punishments successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully got data from punishments.",
+						data: response
+					});
 				}
-				this.log("SUCCESS", "PUNISHMENTS_GET_DATA", `Got data from punishments successfully.`);
-				return cb({
-					status: "success",
-					message: "Successfully got data from punishments.",
-					data: response
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Gets all punishments for a user
@@ -202,26 +205,29 @@ export default {
 	 * @param {string} userId - the id of the user
 	 * @param {Function} cb - gets called with the result
 	 */
-	getPunishmentsForUser: isAdminRequired(async function getPunishmentsForUser(session, userId, cb) {
-		const punishmentModel = await DBModule.runJob("GET_MODEL", { modelName: "punishment" }, this);
+	getPunishmentsForUser: useHasPermission(
+		"punishments.get",
+		async function getPunishmentsForUser(session, userId, cb) {
+			const punishmentModel = await DBModule.runJob("GET_MODEL", { modelName: "punishment" }, this);
 
-		punishmentModel.find({ type: "banUserId", value: userId }, async (err, punishments) => {
-			if (err) {
-				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+			punishmentModel.find({ type: "banUserId", value: userId }, async (err, punishments) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
-				this.log(
-					"ERROR",
-					"GET_PUNISHMENTS_FOR_USER",
-					`Getting punishments for user ${userId} failed. "${err}"`
-				);
+					this.log(
+						"ERROR",
+						"GET_PUNISHMENTS_FOR_USER",
+						`Getting punishments for user ${userId} failed. "${err}"`
+					);
 
-				return cb({ status: "error", message: err });
-			}
+					return cb({ status: "error", message: err });
+				}
 
-			this.log("SUCCESS", "GET_PUNISHMENTS_FOR_USER", `Got punishments for user ${userId} successful.`);
-			return cb({ status: "success", data: { punishments } });
-		});
-	}),
+				this.log("SUCCESS", "GET_PUNISHMENTS_FOR_USER", `Got punishments for user ${userId} successful.`);
+				return cb({ status: "success", data: { punishments } });
+			});
+		}
+	),
 
 	/**
 	 * Returns a punishment by id
@@ -230,7 +236,7 @@ export default {
 	 * @param {string} punishmentId - the punishment id
 	 * @param {Function} cb - gets called with the result
 	 */
-	findOne: isAdminRequired(async function findOne(session, punishmentId, cb) {
+	findOne: useHasPermission("punishments.get", async function findOne(session, punishmentId, cb) {
 		const punishmentModel = await DBModule.runJob("GET_MODEL", { modelName: "punishment" }, this);
 
 		async.waterfall([next => punishmentModel.findOne({ _id: punishmentId }, next)], async (err, punishment) => {
@@ -257,7 +263,7 @@ export default {
 	 * @param {string} expiresAt - the time the ban expires
 	 * @param {Function} cb - gets called with the result
 	 */
-	banIP: isAdminRequired(function banIP(session, value, reason, expiresAt, cb) {
+	banIP: useHasPermission("punishments.banIP", function banIP(session, value, reason, expiresAt, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -356,41 +362,44 @@ export default {
 	 * @param {string} punishmentId - the MongoDB id of the punishment
 	 * @param {Function} cb - gets called with the result
 	 */
-	deactivatePunishment: isAdminRequired(function deactivatePunishment(session, punishmentId, cb) {
-		async.waterfall(
-			[
-				next => {
-					PunishmentsModule.runJob("DEACTIVATE_PUNISHMENT", { punishmentId }, this)
-						.then(punishment => next(null, punishment._doc))
-						.catch(next);
-				}
-			],
-			async (err, punishment) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"DEACTIVATE_PUNISHMENT",
-						`Deactivating punishment ${punishmentId} failed. "${err}"`
-					);
-					return cb({ status: "error", message: err });
-				}
-				this.log("SUCCESS", "DEACTIVATE_PUNISHMENT", `Deactivated punishment ${punishmentId} successful.`);
+	deactivatePunishment: useHasPermission(
+		"punishments.deactivate",
+		function deactivatePunishment(session, punishmentId, cb) {
+			async.waterfall(
+				[
+					next => {
+						PunishmentsModule.runJob("DEACTIVATE_PUNISHMENT", { punishmentId }, this)
+							.then(punishment => next(null, punishment._doc))
+							.catch(next);
+					}
+				],
+				async (err, punishment) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"DEACTIVATE_PUNISHMENT",
+							`Deactivating punishment ${punishmentId} failed. "${err}"`
+						);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "DEACTIVATE_PUNISHMENT", `Deactivated punishment ${punishmentId} successful.`);
 
-				WSModule.runJob("EMIT_TO_ROOM", {
-					room: `admin.punishments`,
-					args: [
-						"event:admin.punishment.updated",
-						{
-							data: {
-								punishment: { ...punishment, status: "Inactive" }
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: `admin.punishments`,
+						args: [
+							"event:admin.punishment.updated",
+							{
+								data: {
+									punishment: { ...punishment, status: "Inactive" }
+								}
 							}
-						}
-					]
-				});
+						]
+					});
 
-				return cb({ status: "success" });
-			}
-		);
-	})
+					return cb({ status: "success" });
+				}
+			);
+		}
+	)
 };

+ 93 - 87
backend/logic/actions/reports.js

@@ -1,6 +1,7 @@
 import async from "async";
 
-import { isAdminRequired, isLoginRequired } from "./hooks";
+import isLoginRequired from "../hooks/loginRequired";
+import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -99,93 +100,98 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "report",
-							blacklistedProperties: [],
-							specialProperties: {
-								createdBy: [
-									{
-										$addFields: {
-											createdByOID: {
-												$convert: {
-													input: "$createdBy",
-													to: "objectId",
-													onError: "unknown",
-													onNull: "unknown"
+	getData: useHasPermission(
+		"admin.view.reports",
+		async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "report",
+								blacklistedProperties: [],
+								specialProperties: {
+									createdBy: [
+										{
+											$addFields: {
+												createdByOID: {
+													$convert: {
+														input: "$createdBy",
+														to: "objectId",
+														onError: "unknown",
+														onNull: "unknown"
+													}
 												}
 											}
-										}
-									},
-									{
-										$lookup: {
-											from: "users",
-											localField: "createdByOID",
-											foreignField: "_id",
-											as: "createdByUser"
-										}
-									},
-									{
-										$unwind: {
-											path: "$createdByUser",
-											preserveNullAndEmptyArrays: true
-										}
-									},
-									{
-										$addFields: {
-											createdByUsername: {
-												$ifNull: ["$createdByUser.username", "unknown"]
+										},
+										{
+											$lookup: {
+												from: "users",
+												localField: "createdByOID",
+												foreignField: "_id",
+												as: "createdByUser"
+											}
+										},
+										{
+											$unwind: {
+												path: "$createdByUser",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										{
+											$addFields: {
+												createdByUsername: {
+													$ifNull: ["$createdByUser.username", "unknown"]
+												}
+											}
+										},
+										{
+											$project: {
+												createdByOID: 0,
+												createdByUser: 0
 											}
 										}
-									},
-									{
-										$project: {
-											createdByOID: 0,
-											createdByUser: 0
-										}
-									}
-								]
+									]
+								},
+								specialQueries: {
+									createdBy: newQuery => ({
+										$or: [newQuery, { createdByUsername: newQuery.createdBy }]
+									})
+								}
 							},
-							specialQueries: {
-								createdBy: newQuery => ({ $or: [newQuery, { createdByUsername: newQuery.createdBy }] })
-							}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, response) => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "REPORTS_GET_DATA", `Failed to get data from reports. "${err}"`);
-					return cb({ status: "error", message: err });
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "REPORTS_GET_DATA", `Failed to get data from reports. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "REPORTS_GET_DATA", `Got data from reports successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully got data from reports.",
+						data: response
+					});
 				}
-				this.log("SUCCESS", "REPORTS_GET_DATA", `Got data from reports successfully.`);
-				return cb({
-					status: "success",
-					message: "Successfully got data from reports.",
-					data: response
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Gets a specific report
@@ -194,7 +200,7 @@ export default {
 	 * @param {string} reportId - the id of the report to return
 	 * @param {Function} cb - gets called with the result
 	 */
-	findOne: isAdminRequired(async function findOne(session, reportId, cb) {
+	findOne: useHasPermission("reports.get", async function findOne(session, reportId, cb) {
 		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
@@ -243,7 +249,7 @@ export default {
 	 * @param {string} songId - the id of the song to index reports for
 	 * @param {Function} cb - gets called with the result
 	 */
-	getReportsForSong: isAdminRequired(async function getReportsForSong(session, songId, cb) {
+	getReportsForSong: useHasPermission("reports.get", async function getReportsForSong(session, songId, cb) {
 		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
@@ -380,7 +386,7 @@ export default {
 	 * @param {boolean} resolved - whether to set to resolved to true or false
 	 * @param {Function} cb - gets called with the result
 	 */
-	resolve: isAdminRequired(async function resolve(session, reportId, resolved, cb) {
+	resolve: useHasPermission("reports.update", async function resolve(session, reportId, resolved, cb) {
 		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
 
 		async.waterfall(
@@ -445,7 +451,7 @@ export default {
 	 * @param {string} issueId - the id of the issue within the report
 	 * @param {Function} cb - gets called with the result
 	 */
-	toggleIssue: isAdminRequired(async function toggleIssue(session, reportId, issueId, cb) {
+	toggleIssue: useHasPermission("reports.update", async function toggleIssue(session, reportId, issueId, cb) {
 		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
 
 		async.waterfall(
@@ -594,7 +600,7 @@ export default {
 	 * @param {object} reportId - the id of the report item we want to remove
 	 * @param {Function} cb - gets called with the result
 	 */
-	remove: isAdminRequired(async function remove(session, reportId, cb) {
+	remove: useHasPermission("reports.remove", async function remove(session, reportId, cb) {
 		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
 
 		async.waterfall(

+ 136 - 132
backend/logic/actions/songs.js

@@ -1,6 +1,7 @@
 import async from "async";
 
-import { isAdminRequired, isLoginRequired } from "./hooks";
+import isLoginRequired from "../hooks/loginRequired";
+import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -47,7 +48,7 @@ export default {
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param cb
 	 */
-	length: isAdminRequired(async function length(session, cb) {
+	length: useHasPermission("songs.get", async function length(session, cb) {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		async.waterfall(
 			[
@@ -79,129 +80,132 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "song",
-							blacklistedProperties: [],
-							specialProperties: {
-								requestedBy: [
-									{
-										$addFields: {
-											requestedByOID: {
-												$convert: {
-													input: "$requestedBy",
-													to: "objectId",
-													onError: "unknown",
-													onNull: "unknown"
+	getData: useHasPermission(
+		"admin.view.songs",
+		async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "song",
+								blacklistedProperties: [],
+								specialProperties: {
+									requestedBy: [
+										{
+											$addFields: {
+												requestedByOID: {
+													$convert: {
+														input: "$requestedBy",
+														to: "objectId",
+														onError: "unknown",
+														onNull: "unknown"
+													}
 												}
 											}
-										}
-									},
-									{
-										$lookup: {
-											from: "users",
-											localField: "requestedByOID",
-											foreignField: "_id",
-											as: "requestedByUser"
-										}
-									},
-									{
-										$addFields: {
-											requestedByUsername: {
-												$ifNull: ["$requestedByUser.username", "unknown"]
+										},
+										{
+											$lookup: {
+												from: "users",
+												localField: "requestedByOID",
+												foreignField: "_id",
+												as: "requestedByUser"
 											}
-										}
-									},
-									{
-										$project: {
-											requestedByOID: 0,
-											requestedByUser: 0
-										}
-									}
-								],
-								verifiedBy: [
-									{
-										$addFields: {
-											verifiedByOID: {
-												$convert: {
-													input: "$verifiedBy",
-													to: "objectId",
-													onError: "unknown",
-													onNull: "unknown"
+										},
+										{
+											$addFields: {
+												requestedByUsername: {
+													$ifNull: ["$requestedByUser.username", "unknown"]
 												}
 											}
-										}
-									},
-									{
-										$lookup: {
-											from: "users",
-											localField: "verifiedByOID",
-											foreignField: "_id",
-											as: "verifiedByUser"
-										}
-									},
-									{
-										$unwind: {
-											path: "$verifiedByUser",
-											preserveNullAndEmptyArrays: true
-										}
-									},
-									{
-										$addFields: {
-											verifiedByUsername: {
-												$ifNull: ["$verifiedByUser.username", "unknown"]
+										},
+										{
+											$project: {
+												requestedByOID: 0,
+												requestedByUser: 0
 											}
 										}
-									},
-									{
-										$project: {
-											verifiedByOID: 0,
-											verifiedByUser: 0
+									],
+									verifiedBy: [
+										{
+											$addFields: {
+												verifiedByOID: {
+													$convert: {
+														input: "$verifiedBy",
+														to: "objectId",
+														onError: "unknown",
+														onNull: "unknown"
+													}
+												}
+											}
+										},
+										{
+											$lookup: {
+												from: "users",
+												localField: "verifiedByOID",
+												foreignField: "_id",
+												as: "verifiedByUser"
+											}
+										},
+										{
+											$unwind: {
+												path: "$verifiedByUser",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										{
+											$addFields: {
+												verifiedByUsername: {
+													$ifNull: ["$verifiedByUser.username", "unknown"]
+												}
+											}
+										},
+										{
+											$project: {
+												verifiedByOID: 0,
+												verifiedByUser: 0
+											}
 										}
-									}
-								]
+									]
+								},
+								specialQueries: {
+									requestedBy: newQuery => ({
+										$or: [newQuery, { requestedByUsername: newQuery.requestedBy }]
+									}),
+									verifiedBy: newQuery => ({
+										$or: [newQuery, { verifiedByUsername: newQuery.verifiedBy }]
+									})
+								}
 							},
-							specialQueries: {
-								requestedBy: newQuery => ({
-									$or: [newQuery, { requestedByUsername: newQuery.requestedBy }]
-								}),
-								verifiedBy: newQuery => ({
-									$or: [newQuery, { verifiedByUsername: newQuery.verifiedBy }]
-								})
-							}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, response) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "SONGS_GET_DATA", `Failed to get data from songs. "${err}"`);
-					return cb({ status: "error", message: err });
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "SONGS_GET_DATA", `Failed to get data from songs. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "SONGS_GET_DATA", `Got data from songs successfully.`);
+					return cb({ status: "success", message: "Successfully got data from songs.", data: response });
 				}
-				this.log("SUCCESS", "SONGS_GET_DATA", `Got data from songs successfully.`);
-				return cb({ status: "success", message: "Successfully got data from songs.", data: response });
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Updates all songs
@@ -209,7 +213,7 @@ export default {
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param cb
 	 */
-	updateAll: isAdminRequired(async function updateAll(session, cb) {
+	updateAll: useHasPermission("songs.updateAll", async function updateAll(session, cb) {
 		this.keepLongJob();
 		this.publishProgress({
 			status: "started",
@@ -266,7 +270,7 @@ export default {
 	 * @param {string} songId - the song id
 	 * @param {Function} cb
 	 */
-	getSongFromSongId: isAdminRequired(function getSongFromSongId(session, songId, cb) {
+	getSongFromSongId: useHasPermission("songs.get", function getSongFromSongId(session, songId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -295,7 +299,7 @@ export default {
 	 * @param {Array} youtubeIds - the song ids
 	 * @param {Function} cb
 	 */
-	getSongsFromYoutubeIds: isAdminRequired(function getSongsFromYoutubeIds(session, youtubeIds, cb) {
+	getSongsFromYoutubeIds: useHasPermission("songs.get", function getSongsFromYoutubeIds(session, youtubeIds, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -339,7 +343,7 @@ export default {
 	 * @param {object} newSong - the song object
 	 * @param {Function} cb
 	 */
-	create: isAdminRequired(async function create(session, newSong, cb) {
+	create: useHasPermission("songs.create", async function create(session, newSong, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -376,7 +380,7 @@ export default {
 	 * @param {object} song - the updated song object
 	 * @param {Function} cb
 	 */
-	update: isAdminRequired(async function update(session, songId, song, cb) {
+	update: useHasPermission("songs.update", async function update(session, songId, song, cb) {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		let existingSong = null;
 		async.waterfall(
@@ -453,7 +457,7 @@ export default {
 	 * @param songId - the song id
 	 * @param cb
 	 */
-	remove: isAdminRequired(async function remove(session, songId, cb) {
+	remove: useHasPermission("songs.remove", async function remove(session, songId, cb) {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 
@@ -717,7 +721,7 @@ export default {
 	 * @param songIds - array of song ids
 	 * @param cb
 	 */
-	removeMany: isAdminRequired(async function remove(session, songIds, cb) {
+	removeMany: useHasPermission("songs.remove", async function remove(session, songIds, cb) {
 		const successful = [];
 		const failed = [];
 
@@ -860,7 +864,7 @@ export default {
 	 * @param songId - the song id
 	 * @param cb
 	 */
-	verify: isAdminRequired(async function add(session, songId, cb) {
+	verify: useHasPermission("songs.verify", async function add(session, songId, cb) {
 		const SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		async.waterfall(
 			[
@@ -919,7 +923,7 @@ export default {
 	 * @param songIds - array of song ids
 	 * @param cb
 	 */
-	verifyMany: isAdminRequired(async function verifyMany(session, songIds, cb) {
+	verifyMany: useHasPermission("songs.verify", async function verifyMany(session, songIds, cb) {
 		const successful = [];
 		const failed = [];
 
@@ -1015,7 +1019,7 @@ export default {
 	 * @param songId - the song id
 	 * @param cb
 	 */
-	unverify: isAdminRequired(async function add(session, songId, cb) {
+	unverify: useHasPermission("songs.verify", async function add(session, songId, cb) {
 		const SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		async.waterfall(
 			[
@@ -1080,7 +1084,7 @@ export default {
 	 * @param songIds - array of song ids
 	 * @param cb
 	 */
-	unverifyMany: isAdminRequired(async function unverifyMany(session, songIds, cb) {
+	unverifyMany: useHasPermission("songs.verify", async function unverifyMany(session, songIds, cb) {
 		const successful = [];
 		const failed = [];
 
@@ -1183,7 +1187,7 @@ export default {
 	 * @param session
 	 * @param cb
 	 */
-	getGenres: isAdminRequired(function getGenres(session, cb) {
+	getGenres: useHasPermission("songs.get", function getGenres(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1222,7 +1226,7 @@ export default {
 	 * @param songIds Array of songIds to apply genres to
 	 * @param cb
 	 */
-	editGenres: isAdminRequired(async function editGenres(session, method, genres, songIds, cb) {
+	editGenres: useHasPermission("songs.update", async function editGenres(session, method, genres, songIds, cb) {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 
 		this.keepLongJob();
@@ -1311,7 +1315,7 @@ export default {
 	 * @param session
 	 * @param cb
 	 */
-	getArtists: isAdminRequired(function getArtists(session, cb) {
+	getArtists: useHasPermission("songs.get", function getArtists(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1350,7 +1354,7 @@ export default {
 	 * @param songIds Array of songIds to apply artists to
 	 * @param cb
 	 */
-	editArtists: isAdminRequired(async function editArtists(session, method, artists, songIds, cb) {
+	editArtists: useHasPermission("songs.update", async function editArtists(session, method, artists, songIds, cb) {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 
 		this.keepLongJob();
@@ -1439,7 +1443,7 @@ export default {
 	 * @param session
 	 * @param cb
 	 */
-	getTags: isAdminRequired(function getTags(session, cb) {
+	getTags: useHasPermission("songs.get", function getTags(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1478,7 +1482,7 @@ export default {
 	 * @param songIds Array of songIds to apply tags to
 	 * @param cb
 	 */
-	editTags: isAdminRequired(async function editTags(session, method, tags, songIds, cb) {
+	editTags: useHasPermission("songs.update", async function editTags(session, method, tags, songIds, cb) {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 
 		this.keepLongJob();

+ 283 - 241
backend/logic/actions/stations.js

@@ -2,7 +2,8 @@ import async from "async";
 import mongoose from "mongoose";
 import config from "config";
 
-import { isLoginRequired, isOwnerRequired, isAdminRequired } from "./hooks";
+import { hasPermission, useHasPermission, getUserPermissions } from "../hooks/hasPermission";
+import isLoginRequired from "../hooks/loginRequired";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -51,37 +52,13 @@ CacheModule.runJob("SUB", {
 					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
 					if (!socket) return;
 					const { session } = socket;
-
-					if (session.sessionId) {
-						CacheModule.runJob("HGET", {
-							table: "sessions",
-							key: session.sessionId
-						}).then(session => {
-							if (session)
-								DBModule.runJob(
-									"GET_MODEL",
-									{
-										modelName: "user"
-									},
-									this
-								).then(userModel => {
-									userModel.findOne({ _id: session.userId }, (err, user) => {
-										if (user && user.role === "admin")
-											socket.dispatch("event:station.userCount.updated", {
-												data: { stationId, count }
-											});
-										else if (
-											user &&
-											station.type === "community" &&
-											station.owner === session.userId
-										)
-											socket.dispatch("event:station.userCount.updated", {
-												data: { stationId, count }
-											});
-									});
-								});
-						});
-					}
+					hasPermission("stations.view", session, stationId)
+						.then(() => {
+							socket.dispatch("event:station.userCount.updated", {
+								data: { stationId, count }
+							});
+						})
+						.catch(() => {});
 				});
 			}
 		});
@@ -273,8 +250,6 @@ CacheModule.runJob("SUB", {
 CacheModule.runJob("SUB", {
 	channel: "station.create",
 	cb: async stationId => {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
-
 		StationsModule.runJob("INITIALIZE_STATION", { stationId }).then(async res => {
 			const { station } = res;
 			station.userCount = StationsModule.usersPerStationCount[stationId] || 0;
@@ -298,22 +273,11 @@ CacheModule.runJob("SUB", {
 					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
 					if (!socket) return;
 					const { session } = socket;
-
-					if (session.sessionId) {
-						CacheModule.runJob("HGET", {
-							table: "sessions",
-							key: session.sessionId
-						}).then(session => {
-							if (session) {
-								userModel.findOne({ _id: session.userId }, (err, user) => {
-									if (user && user.role === "admin")
-										socket.dispatch("event:station.created", { data: { station } });
-									else if (user && station.type === "community" && station.owner === session.userId)
-										socket.dispatch("event:station.created", { data: { station } });
-								});
-							}
-						});
-					}
+					hasPermission("stations.view", session, stationId)
+						.then(() => {
+							socket.dispatch("event:station.created", { data: { station } });
+						})
+						.catch(() => {});
 				});
 			}
 		});
@@ -430,30 +394,26 @@ export default {
 											},
 											this
 										)
-											.then(exists => {
-												if (exists && session.userId && station.privacy !== "public") {
-													DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
-														.then(userModel => {
-															userModel.findOne({ _id: session.userId }, (err, user) => {
-																if (err) return callback(err);
-																if (
-																	(user.role !== "admin" &&
-																		station.owner !== session.userId) ||
-																	(adminFilter &&
-																		user.role === "admin" &&
-																		station.owner !== session.userId)
-																) {
-																	return callback(null, false);
-																}
-																return callback(null, exists);
-															});
-														})
-														.catch(callback);
-												} else if (exists && !session.userId && station.privacy !== "public")
-													callback(null, false);
-												else callback(null, exists);
-											})
+											.then(exists => callback(null, exists))
 											.catch(callback);
+									},
+
+									(exists, callback) => {
+										if (!exists) callback(null, false, false);
+										else if (station.privacy === "public") callback(null, true, true);
+										else
+											hasPermission("stations.index", session.userId, station._id)
+												.then(() => callback(null, true, true))
+												.catch(() => callback(null, true, false));
+									},
+
+									(exists, canIndex, callback) => {
+										if (!exists) callback(null, false);
+										else if (!canIndex && !adminFilter)
+											hasPermission("stations.index.other", session.userId)
+												.then(() => callback(null, true))
+												.catch(() => callback(null, false));
+										else callback(null, canIndex);
 									}
 								],
 								(err, exists) => {
@@ -496,93 +456,96 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "station",
-							blacklistedProperties: [],
-							specialProperties: {
-								owner: [
-									{
-										$addFields: {
-											ownerOID: {
-												$convert: {
-													input: "$owner",
-													to: "objectId",
-													onError: "unknown",
-													onNull: "unknown"
+	getData: useHasPermission(
+		"admin.view.stations",
+		async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "station",
+								blacklistedProperties: [],
+								specialProperties: {
+									owner: [
+										{
+											$addFields: {
+												ownerOID: {
+													$convert: {
+														input: "$owner",
+														to: "objectId",
+														onError: "unknown",
+														onNull: "unknown"
+													}
 												}
 											}
-										}
-									},
-									{
-										$lookup: {
-											from: "users",
-											localField: "ownerOID",
-											foreignField: "_id",
-											as: "ownerUser"
-										}
-									},
-									{
-										$unwind: {
-											path: "$ownerUser",
-											preserveNullAndEmptyArrays: true
-										}
-									},
-									{
-										$addFields: {
-											ownerUsername: {
-												$cond: [
-													{ $eq: [{ $type: "$owner" }, "string"] },
-													{ $ifNull: ["$ownerUser.username", "unknown"] },
-													"none"
-												]
+										},
+										{
+											$lookup: {
+												from: "users",
+												localField: "ownerOID",
+												foreignField: "_id",
+												as: "ownerUser"
+											}
+										},
+										{
+											$unwind: {
+												path: "$ownerUser",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										{
+											$addFields: {
+												ownerUsername: {
+													$cond: [
+														{ $eq: [{ $type: "$owner" }, "string"] },
+														{ $ifNull: ["$ownerUser.username", "unknown"] },
+														"none"
+													]
+												}
+											}
+										},
+										{
+											$project: {
+												ownerOID: 0,
+												ownerUser: 0
 											}
 										}
-									},
-									{
-										$project: {
-											ownerOID: 0,
-											ownerUser: 0
-										}
-									}
-								]
+									]
+								},
+								specialQueries: {
+									owner: newQuery => ({ $or: [newQuery, { ownerUsername: newQuery.owner }] })
+								}
 							},
-							specialQueries: {
-								owner: newQuery => ({ $or: [newQuery, { ownerUsername: newQuery.owner }] })
-							}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, response) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "STATIONS_GET_DATA", `Failed to get data from stations. "${err}"`);
-					return cb({ status: "error", message: err });
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "STATIONS_GET_DATA", `Failed to get data from stations. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "STATIONS_GET_DATA", `Got data from stations successfully.`);
+					return cb({ status: "success", message: "Successfully got data from stations.", data: response });
 				}
-				this.log("SUCCESS", "STATIONS_GET_DATA", `Got data from stations successfully.`);
-				return cb({ status: "success", message: "Successfully got data from stations.", data: response });
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Obtains basic metadata of a station in order to format an activity
@@ -899,6 +862,18 @@ export default {
 					}
 
 					return next(null, data);
+				},
+
+				(data, next) => {
+					getUserPermissions(session.userId, data._id)
+						.then(permissions => {
+							data.permissions = permissions;
+							next(null, data);
+						})
+						.catch(() => {
+							data.permissions = {};
+							next(null, data);
+						});
 				}
 			],
 			async (err, data) => {
@@ -988,6 +963,18 @@ export default {
 					};
 
 					next(null, data);
+				},
+
+				(data, next) => {
+					getUserPermissions(session.userId, data._id)
+						.then(permissions => {
+							data.permissions = permissions;
+							next(null, data);
+						})
+						.catch(() => {
+							data.permissions = {};
+							next(null, data);
+						});
 				}
 			],
 			async (err, data) => {
@@ -1231,9 +1218,15 @@ export default {
 	 * @param stationId - the station id
 	 * @param cb
 	 */
-	forceSkip: isOwnerRequired(function forceSkip(session, stationId, cb) {
+	forceSkip(session, stationId, cb) {
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.skip", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					StationsModule.runJob("GET_STATION", { stationId }, this)
 						.then(station => {
@@ -1261,7 +1254,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Leaves the user's current station
@@ -1313,14 +1306,21 @@ export default {
 	 * @param session
 	 * @param stationId - the station id
 	 * @param station - updated station object
+	 * @param newStation
 	 * @param cb
 	 */
-	update: isOwnerRequired(async function update(session, stationId, newStation, cb) {
+	async update(session, stationId, newStation, cb) {
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.update", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					stationModel.findOne({ _id: stationId }, next);
 				},
@@ -1406,7 +1406,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Pauses a station
@@ -1415,7 +1415,7 @@ export default {
 	 * @param stationId - the station id
 	 * @param cb
 	 */
-	pause: isOwnerRequired(async function pause(session, stationId, cb) {
+	async pause(session, stationId, cb) {
 		const stationModel = await DBModule.runJob(
 			"GET_MODEL",
 			{
@@ -1425,6 +1425,12 @@ export default {
 		);
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.playback.toggle", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					StationsModule.runJob("GET_STATION", { stationId }, this)
 						.then(station => {
@@ -1469,7 +1475,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Resumes a station
@@ -1478,7 +1484,7 @@ export default {
 	 * @param stationId - the station id
 	 * @param cb
 	 */
-	resume: isOwnerRequired(async function resume(session, stationId, cb) {
+	async resume(session, stationId, cb) {
 		const stationModel = await DBModule.runJob(
 			"GET_MODEL",
 			{
@@ -1488,6 +1494,12 @@ export default {
 		);
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.playback.toggle", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					StationsModule.runJob("GET_STATION", { stationId }, this)
 						.then(station => {
@@ -1539,7 +1551,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Removes a station
@@ -1548,12 +1560,18 @@ export default {
 	 * @param stationId - the station id
 	 * @param cb
 	 */
-	remove: isOwnerRequired(async function remove(session, stationId, cb) {
+	async remove(session, stationId, cb) {
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.remove", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					stationModel.findById(stationId, (err, station) => {
 						if (err) return next(err);
@@ -1617,7 +1635,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Create a station
@@ -1627,7 +1645,6 @@ export default {
 	 * @param cb
 	 */
 	create: isLoginRequired(async function create(session, data, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 
@@ -1699,14 +1716,10 @@ export default {
 					if (blacklist.indexOf(data.name) !== -1)
 						return next("That name is blacklisted. Please use a different name.");
 
-					if (data.type === "official") {
-						return userModel.findOne({ _id: session.userId }, (err, user) => {
-							if (err) return next(err);
-							if (!user) return next("User not found.");
-							if (user.role !== "admin") return next("Admin required.");
-							return next();
-						});
-					}
+					if (data.type === "official")
+						return hasPermission("stations.create.official", session)
+							.then(() => next())
+							.catch(() => next("Insufficient permissions."));
 					return next();
 				},
 
@@ -1803,8 +1816,6 @@ export default {
 	 * @param cb
 	 */
 	addToQueue: isLoginRequired(async function addToQueue(session, stationId, youtubeId, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-
 		async.waterfall(
 			[
 				next => {
@@ -1823,12 +1834,9 @@ export default {
 						station.requests.access === "owner" ||
 						(station.requests.access === "user" && station.privacy === "private")
 					) {
-						return userModel.findOne({ _id: session.userId }, (err, user) => {
-							if (err) return next(err);
-							if (user.role !== "admin" && station.owner !== session.userId)
-								return next("You do not have permission to add songs to queue.");
-							return next(null, station);
-						});
+						return hasPermission("stations.request", session, stationId)
+							.then(() => next(null, station))
+							.catch(() => next("You do not have permission to add songs to queue."));
 					}
 
 					return next(null, station);
@@ -1895,9 +1903,15 @@ export default {
 	 * @param youtubeId - the youtube id
 	 * @param cb
 	 */
-	removeFromQueue: isOwnerRequired(async function removeFromQueue(session, stationId, youtubeId, cb) {
+	async removeFromQueue(session, stationId, youtubeId, cb) {
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.queue.remove", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					if (!youtubeId) return next("Invalid youtube id.");
 					return StationsModule.runJob("REMOVE_FROM_QUEUE", { stationId, youtubeId }, this)
@@ -1928,7 +1942,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Gets the queue from a station
@@ -1995,11 +2009,17 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - callback
 	 */
-	repositionSongInQueue: isOwnerRequired(async function repositionQueue(session, stationId, song, cb) {
+	async repositionSongInQueue(session, stationId, song, cb) {
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.queue.reposition", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					if (!song || !song.youtubeId) return next("You must provide a song to reposition.");
 					return next();
@@ -2065,7 +2085,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Autofill a playlist in a station
@@ -2075,9 +2095,15 @@ export default {
 	 * @param playlistId - the playlist id
 	 * @param cb
 	 */
-	autofillPlaylist: isOwnerRequired(async function autofillPlaylist(session, stationId, playlistId, cb) {
+	async autofillPlaylist(session, stationId, playlistId, cb) {
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.autofill", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					StationsModule.runJob("GET_STATION", { stationId }, this)
 						.then(station => next(null, station))
@@ -2134,7 +2160,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Remove autofilled playlist from a station
@@ -2144,9 +2170,15 @@ export default {
 	 * @param playlistId - the playlist id
 	 * @param cb
 	 */
-	removeAutofillPlaylist: isOwnerRequired(async function removeAutofillPlaylist(session, stationId, playlistId, cb) {
+	async removeAutofillPlaylist(session, stationId, playlistId, cb) {
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.autofill", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					StationsModule.runJob("GET_STATION", { stationId }, this)
 						.then(station => next(null, station))
@@ -2201,7 +2233,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Blacklist a playlist in a station
@@ -2211,9 +2243,15 @@ export default {
 	 * @param playlistId - the playlist id
 	 * @param cb
 	 */
-	blacklistPlaylist: isOwnerRequired(async function blacklistPlaylist(session, stationId, playlistId, cb) {
+	async blacklistPlaylist(session, stationId, playlistId, cb) {
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.blacklist", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					StationsModule.runJob("GET_STATION", { stationId }, this)
 						.then(station => next(null, station))
@@ -2268,7 +2306,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Remove blacklisted a playlist from a station
@@ -2278,14 +2316,15 @@ export default {
 	 * @param playlistId - the playlist id
 	 * @param cb
 	 */
-	removeBlacklistedPlaylist: isOwnerRequired(async function removeBlacklistedPlaylist(
-		session,
-		stationId,
-		playlistId,
-		cb
-	) {
+	async removeBlacklistedPlaylist(session, stationId, playlistId, cb) {
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.blacklist", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					StationsModule.runJob("GET_STATION", { stationId }, this)
 						.then(station => next(null, station))
@@ -2339,7 +2378,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	favoriteStation: isLoginRequired(async function favoriteStation(session, stationId, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
@@ -2468,51 +2507,54 @@ export default {
 	 * @param {object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 */
-	clearEveryStationQueue: isAdminRequired(async function clearEveryStationQueue(session, cb) {
-		this.keepLongJob();
-		this.publishProgress({
-			status: "started",
-			title: "Clear every station queue",
-			message: "Clearing every station queue.",
-			id: this.toString()
-		});
-		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
-		await CacheModule.runJob(
-			"PUB",
-			{
-				channel: "longJob.added",
-				value: { jobId: this.toString(), userId: session.userId }
-			},
-			this
-		);
-
-		async.waterfall(
-			[
-				next => {
-					StationsModule.runJob("CLEAR_EVERY_STATION_QUEUE", {}, this)
-						.then(() => next())
-						.catch(next);
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "CLEAR_EVERY_STATION_QUEUE", `Clearing every station queue failed. "${err}"`);
+	clearEveryStationQueue: useHasPermission(
+		"stations.clearEveryStationQueue",
+		async function clearEveryStationQueue(session, cb) {
+			this.keepLongJob();
+			this.publishProgress({
+				status: "started",
+				title: "Clear every station queue",
+				message: "Clearing every station queue.",
+				id: this.toString()
+			});
+			await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+			await CacheModule.runJob(
+				"PUB",
+				{
+					channel: "longJob.added",
+					value: { jobId: this.toString(), userId: session.userId }
+				},
+				this
+			);
+
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("CLEAR_EVERY_STATION_QUEUE", {}, this)
+							.then(() => next())
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "CLEAR_EVERY_STATION_QUEUE", `Clearing every station queue failed. "${err}"`);
+						this.publishProgress({
+							status: "error",
+							message: err
+						});
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "CLEAR_EVERY_STATION_QUEUE", "Clearing every station queue was successful.");
 					this.publishProgress({
-						status: "error",
-						message: err
+						status: "success",
+						message: "Successfully cleared every station queue."
 					});
-					return cb({ status: "error", message: err });
+					return cb({ status: "success", message: "Successfully cleared every station queue." });
 				}
-				this.log("SUCCESS", "CLEAR_EVERY_STATION_QUEUE", "Clearing every station queue was successful.");
-				this.publishProgress({
-					status: "success",
-					message: "Successfully cleared every station queue."
-				});
-				return cb({ status: "success", message: "Successfully cleared every station queue." });
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Reset a station queue
@@ -2521,7 +2563,7 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - gets called with the result
 	 */
-	resetQueue: isAdminRequired(async function resetQueue(session, stationId, cb) {
+	resetQueue: useHasPermission("stations.queue.reset", async function resetQueue(session, stationId, cb) {
 		async.waterfall(
 			[
 				next => {

+ 208 - 208
backend/logic/actions/users.js

@@ -6,7 +6,8 @@ import mongoose from "mongoose";
 import axios from "axios";
 import bcrypt from "bcrypt";
 import sha256 from "sha256";
-import { isAdminRequired, isLoginRequired } from "./hooks";
+import isLoginRequired from "../hooks/loginRequired";
+import { hasPermission, useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -245,71 +246,74 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "user",
-							blacklistedProperties: [
-								"services.password.password",
-								"services.password.reset.code",
-								"services.password.reset.expires",
-								"services.password.set.code",
-								"services.password.set.expires",
-								"services.github.access_token",
-								"email.verificationToken"
-							],
-							specialProperties: {
-								hasPassword: [
-									{
-										$addFields: {
-											hasPassword: {
-												$cond: [
-													{ $eq: [{ $type: "$services.password.password" }, "string"] },
-													true,
-													false
-												]
+	getData: useHasPermission(
+		"users.getData",
+		async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "user",
+								blacklistedProperties: [
+									"services.password.password",
+									"services.password.reset.code",
+									"services.password.reset.expires",
+									"services.password.set.code",
+									"services.password.set.expires",
+									"services.github.access_token",
+									"email.verificationToken"
+								],
+								specialProperties: {
+									hasPassword: [
+										{
+											$addFields: {
+												hasPassword: {
+													$cond: [
+														{ $eq: [{ $type: "$services.password.password" }, "string"] },
+														true,
+														false
+													]
+												}
 											}
 										}
-									}
-								]
+									]
+								},
+								specialQueries: {}
 							},
-							specialQueries: {}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, response) => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "USERS_GET_DATA", `Failed to get data from users. "${err}"`);
-					return cb({ status: "error", message: err });
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "USERS_GET_DATA", `Failed to get data from users. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "USERS_GET_DATA", `Got data from users successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully got data from users.",
+						data: response
+					});
 				}
-				this.log("SUCCESS", "USERS_GET_DATA", `Got data from users successfully.`);
-				return cb({
-					status: "success",
-					message: "Successfully got data from users.",
-					data: response
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Removes all data held on a user, including their ability to login
@@ -541,7 +545,7 @@ export default {
 	 * @param {string} userId - the user id that is going to be banned
 	 * @param {Function} cb - gets called with the result
 	 */
-	adminRemove: isAdminRequired(async function adminRemove(session, userId, cb) {
+	adminRemove: useHasPermission("users.remove", async function adminRemove(session, userId, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
@@ -1238,17 +1242,13 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	removeSessions: isLoginRequired(async function removeSessions(session, userId, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-
 		async.waterfall(
 			[
 				next => {
-					userModel.findOne({ _id: session.userId }, (err, user) => {
-						if (err) return next(err);
-						if (user.role !== "admin" && session.userId !== userId)
-							return next("Only admins and the owner of the account can remove their sessions.");
-						return next();
-					});
+					if (session.userId === userId) return next();
+					return hasPermission("users.remove.sessions", session)
+						.then(() => next())
+						.catch(() => next("Only admins and the owner of the account can remove their sessions."));
 				},
 
 				next => {
@@ -1855,7 +1855,7 @@ export default {
 	 * @param {string} userId - the userId of the person we are trying to get the username from
 	 * @param {Function} cb - gets called with the result
 	 */
-	getUserFromId: isAdminRequired(async function getUserFromId(session, userId, cb) {
+	getUserFromId: useHasPermission("users.get", async function getUserFromId(session, userId, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 		userModel
 			.findById(userId)
@@ -1984,14 +1984,13 @@ export default {
 		async.waterfall(
 			[
 				next => {
-					if (updatingUserId === session.userId) return next(null, true);
-					return userModel.findOne({ _id: session.userId }, next);
+					if (updatingUserId === session.userId) return next();
+					return hasPermission("users.update", session)
+						.then(() => next())
+						.catch(() => next("Invalid permissions."));
 				},
 
-				(user, next) => {
-					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
-					return userModel.findOne({ _id: updatingUserId }, next);
-				},
+				next => userModel.findOne({ _id: updatingUserId }, next),
 
 				(user, next) => {
 					if (!user) return next("User not found.");
@@ -2077,14 +2076,13 @@ export default {
 		async.waterfall(
 			[
 				next => {
-					if (updatingUserId === session.userId) return next(null, true);
-					return userModel.findOne({ _id: session.userId }, next);
+					if (updatingUserId === session.userId) return next();
+					return hasPermission("users.update", session)
+						.then(() => next())
+						.catch(() => next("Invalid permissions."));
 				},
 
-				(user, next) => {
-					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
-					return userModel.findOne({ _id: updatingUserId }, next);
-				},
+				next => userModel.findOne({ _id: updatingUserId }, next),
 
 				(user, next) => {
 					if (!user) return next("User not found.");
@@ -2188,14 +2186,13 @@ export default {
 		async.waterfall(
 			[
 				next => {
-					if (updatingUserId === session.userId) return next(null, true);
-					return userModel.findOne({ _id: session.userId }, next);
+					if (updatingUserId === session.userId) return next();
+					return hasPermission("users.update", session)
+						.then(() => next())
+						.catch(() => next("Invalid permissions."));
 				},
 
-				(user, next) => {
-					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
-					return userModel.findOne({ _id: updatingUserId }, next);
-				},
+				next => userModel.findOne({ _id: updatingUserId }, next),
 
 				(user, next) => {
 					if (!user) return next("User not found.");
@@ -2259,14 +2256,13 @@ export default {
 		async.waterfall(
 			[
 				next => {
-					if (updatingUserId === session.userId) return next(null, true);
-					return userModel.findOne({ _id: session.userId }, next);
+					if (updatingUserId === session.userId) return next();
+					return hasPermission("users.update", session)
+						.then(() => next())
+						.catch(() => next("Invalid permissions."));
 				},
 
-				(user, next) => {
-					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
-					return userModel.findOne({ _id: updatingUserId }, next);
-				},
+				next => userModel.findOne({ _id: updatingUserId }, next),
 
 				(user, next) => {
 					if (!user) return next("User not found.");
@@ -2330,14 +2326,13 @@ export default {
 		async.waterfall(
 			[
 				next => {
-					if (updatingUserId === session.userId) return next(null, true);
-					return userModel.findOne({ _id: session.userId }, next);
+					if (updatingUserId === session.userId) return next();
+					return hasPermission("users.update", session)
+						.then(() => next())
+						.catch(() => next("Invalid permissions."));
 				},
 
-				(user, next) => {
-					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
-					return userModel.findOne({ _id: updatingUserId }, next);
-				},
+				next => userModel.findOne({ _id: updatingUserId }, next),
 
 				(user, next) => {
 					if (!user) return next("User not found.");
@@ -2395,14 +2390,13 @@ export default {
 		async.waterfall(
 			[
 				next => {
-					if (updatingUserId === session.userId) return next(null, true);
-					return userModel.findOne({ _id: session.userId }, next);
+					if (updatingUserId === session.userId) return next();
+					return hasPermission("users.update", session)
+						.then(() => next())
+						.catch(() => next("Invalid permissions."));
 				},
 
-				(user, next) => {
-					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
-					return userModel.findOne({ _id: updatingUserId }, next);
-				},
+				next => userModel.findOne({ _id: updatingUserId }, next),
 
 				(user, next) => {
 					if (!user) return next("User not found.");
@@ -2458,7 +2452,7 @@ export default {
 	 * @param {string} newRole - the new role
 	 * @param {Function} cb - gets called with the result
 	 */
-	updateRole: isAdminRequired(async function updateRole(session, updatingUserId, newRole, cb) {
+	updateRole: useHasPermission("users.update", async function updateRole(session, updatingUserId, newRole, cb) {
 		newRole = newRole.toLowerCase();
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
@@ -2989,73 +2983,76 @@ export default {
 	 * @param {string} email - the email of the user for which the password reset is intended
 	 * @param {Function} cb - gets called with the result
 	 */
-	adminRequestPasswordReset: isAdminRequired(async function adminRequestPasswordReset(session, userId, cb) {
-		const code = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 8 }, this);
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-
-		const resetPasswordRequestSchema = await MailModule.runJob(
-			"GET_SCHEMA",
-			{ schemaName: "resetPasswordRequest" },
-			this
-		);
-
-		async.waterfall(
-			[
-				next => userModel.findOne({ _id: userId }, 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.");
-					return next();
-				},
+	adminRequestPasswordReset: useHasPermission(
+		"users.requestPasswordReset",
+		async function adminRequestPasswordReset(session, userId, cb) {
+			const code = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 8 }, this);
+			const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+			const resetPasswordRequestSchema = await MailModule.runJob(
+				"GET_SCHEMA",
+				{ schemaName: "resetPasswordRequest" },
+				this
+			);
+
+			async.waterfall(
+				[
+					next => userModel.findOne({ _id: userId }, 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.");
+						return next();
+					},
 
-				next => {
-					const expires = new Date();
-					expires.setDate(expires.getDate() + 1);
-					userModel.findOneAndUpdate(
-						{ _id: userId },
-						{
-							$set: {
-								"services.password.reset": {
-									code,
-									expires
+					next => {
+						const expires = new Date();
+						expires.setDate(expires.getDate() + 1);
+						userModel.findOneAndUpdate(
+							{ _id: userId },
+							{
+								$set: {
+									"services.password.reset": {
+										code,
+										expires
+									}
 								}
-							}
-						},
-						{ runValidators: true },
-						next
-					);
-				},
+							},
+							{ runValidators: true },
+							next
+						);
+					},
+
+					(user, next) => {
+						resetPasswordRequestSchema(user.email.address, user.username, code, next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"ADMINREQUEST_PASSWORD_RESET",
+							`User '${userId}' failed to get a password reset. '${err}'`
+						);
+						return cb({ status: "error", message: err });
+					}
 
-				(user, next) => {
-					resetPasswordRequestSchema(user.email.address, user.username, code, next);
-				}
-			],
-			async err => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
-						"ERROR",
-						"ADMINREQUEST_PASSWORD_RESET",
-						`User '${userId}' failed to get a password reset. '${err}'`
+						"SUCCESS",
+						"ADMIN_REQUEST_PASSWORD_RESET",
+						`User '${userId}' successfully got sent a password reset.`
 					);
-					return cb({ status: "error", message: err });
-				}
-
-				this.log(
-					"SUCCESS",
-					"ADMIN_REQUEST_PASSWORD_RESET",
-					`User '${userId}' successfully got sent a password reset.`
-				);
 
-				return cb({
-					status: "success",
-					message: "Successfully requested password reset for user."
-				});
-			}
-		);
-	}),
+					return cb({
+						status: "success",
+						message: "Successfully requested password reset for user."
+					});
+				}
+			);
+		}
+	),
 
 	/**
 	 * Verifies a reset code
@@ -3176,48 +3173,51 @@ export default {
 	 * @param {string} userId - the user id of the person to resend the email to
 	 * @param {Function} cb - gets called with the result
 	 */
-	resendVerifyEmail: isAdminRequired(async function resendVerifyEmail(session, userId, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
-
-		async.waterfall(
-			[
-				next => userModel.findOne({ _id: userId }, next),
+	resendVerifyEmail: useHasPermission(
+		"users.resendVerifyEmail",
+		async function resendVerifyEmail(session, userId, cb) {
+			const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+			const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
+
+			async.waterfall(
+				[
+					next => userModel.findOne({ _id: userId }, next),
+
+					(user, next) => {
+						if (!user) return next("User not found.");
+						if (user.email.verified) return next("The user's email is already verified.");
+						return next(null, user);
+					},
 
-				(user, next) => {
-					if (!user) return next("User not found.");
-					if (user.email.verified) return next("The user's email is already verified.");
-					return next(null, user);
-				},
+					(user, next) => {
+						verifyEmailSchema(user.email.address, user.username, user.email.verificationToken, err => {
+							next(err);
+						});
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+						this.log(
+							"ERROR",
+							"RESEND_VERIFY_EMAIL",
+							`Couldn't resend verify email for user "${userId}". '${err}'`
+						);
 
-				(user, next) => {
-					verifyEmailSchema(user.email.address, user.username, user.email.verificationToken, err => {
-						next(err);
-					});
-				}
-			],
-			async err => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return cb({ status: "error", message: err });
+					}
 
-					this.log(
-						"ERROR",
-						"RESEND_VERIFY_EMAIL",
-						`Couldn't resend verify email for user "${userId}". '${err}'`
-					);
+					this.log("SUCCESS", "RESEND_VERIFY_EMAIL", `Resent verify email for user "${userId}".`);
 
-					return cb({ status: "error", message: err });
+					return cb({
+						status: "success",
+						message: "Email resent successfully."
+					});
 				}
-
-				this.log("SUCCESS", "RESEND_VERIFY_EMAIL", `Resent verify email for user "${userId}".`);
-
-				return cb({
-					status: "success",
-					message: "Email resent successfully."
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Bans a user by userId
@@ -3228,7 +3228,7 @@ export default {
 	 * @param {string} expiresAt - the time the ban expires
 	 * @param {Function} cb - gets called with the result
 	 */
-	banUserById: isAdminRequired(function banUserById(session, userId, reason, expiresAt, cb) {
+	banUserById: useHasPermission("users.ban", function banUserById(session, userId, reason, expiresAt, cb) {
 		async.waterfall(
 			[
 				next => {

+ 3 - 3
backend/logic/actions/utils.js

@@ -1,6 +1,6 @@
 import async from "async";
 
-import { isAdminRequired } from "./hooks";
+import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -9,7 +9,7 @@ const UtilsModule = moduleManager.modules.utils;
 const WSModule = moduleManager.modules.ws;
 
 export default {
-	getModules: isAdminRequired(function getModules(session, cb) {
+	getModules: useHasPermission("utils.getModules", function getModules(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -51,7 +51,7 @@ export default {
 		);
 	}),
 
-	getModule: isAdminRequired(function getModule(session, moduleName, cb) {
+	getModule: useHasPermission("utils.getModules", function getModule(session, moduleName, cb) {
 		async.waterfall(
 			[
 				next => {

+ 351 - 357
backend/logic/actions/youtube.js

@@ -1,7 +1,8 @@
 import mongoose from "mongoose";
 import async from "async";
 
-import { isAdminRequired, isLoginRequired } from "./hooks";
+import isLoginRequired from "../hooks/loginRequired";
+import { useHasPermission } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -18,7 +19,7 @@ export default {
 	 *
 	 * @returns {{status: string, data: object}}
 	 */
-	getQuotaStatus: isAdminRequired(function getQuotaStatus(session, fromDate, cb) {
+	getQuotaStatus: useHasPermission("admin.view.youtube", function getQuotaStatus(session, fromDate, cb) {
 		YouTubeModule.runJob("GET_QUOTA_STATUS", { fromDate }, this)
 			.then(response => {
 				this.log("SUCCESS", "YOUTUBE_GET_QUOTA_STATUS", `Getting quota status was successful.`);
@@ -41,29 +42,25 @@ export default {
 	 * @param dataType - either usage or count
 	 * @returns {{status: string, data: object}}
 	 */
-	getQuotaChartData: isAdminRequired(function getQuotaChartData(
-		session,
-		timePeriod,
-		startDate,
-		endDate,
-		dataType,
-		cb
-	) {
-		YouTubeModule.runJob(
-			"GET_QUOTA_CHART_DATA",
-			{ timePeriod, startDate: new Date(startDate), endDate: new Date(endDate), dataType },
-			this
-		)
-			.then(data => {
-				this.log("SUCCESS", "YOUTUBE_GET_QUOTA_CHART_DATA", `Getting quota chart data was successful.`);
-				return cb({ status: "success", data });
-			})
-			.catch(async err => {
-				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-				this.log("ERROR", "YOUTUBE_GET_QUOTA_CHART_DATA", `Getting quota chart data failed. "${err}"`);
-				return cb({ status: "error", message: err });
-			});
-	}),
+	getQuotaChartData: useHasPermission(
+		"admin.view.youtube",
+		function getQuotaChartData(session, timePeriod, startDate, endDate, dataType, cb) {
+			YouTubeModule.runJob(
+				"GET_QUOTA_CHART_DATA",
+				{ timePeriod, startDate: new Date(startDate), endDate: new Date(endDate), dataType },
+				this
+			)
+				.then(data => {
+					this.log("SUCCESS", "YOUTUBE_GET_QUOTA_CHART_DATA", `Getting quota chart data was successful.`);
+					return cb({ status: "success", data });
+				})
+				.catch(async err => {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "YOUTUBE_GET_QUOTA_CHART_DATA", `Getting quota chart data failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				});
+		}
+	),
 
 	/**
 	 * Gets api requests, used in the admin youtube page by the AdvancedTable component
@@ -77,65 +74,59 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getApiRequests: isAdminRequired(async function getApiRequests(
-		session,
-		page,
-		pageSize,
-		properties,
-		sort,
-		queries,
-		operator,
-		cb
-	) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "youtubeApiRequest",
-							blacklistedProperties: [],
-							specialProperties: {},
-							specialQueries: {}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, response) => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "YOUTUBE_GET_API_REQUESTS", `Failed to get YouTube api requests. "${err}"`);
-					return cb({ status: "error", message: err });
+	getApiRequests: useHasPermission(
+		"admin.view.youtube",
+		async function getApiRequests(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "youtubeApiRequest",
+								blacklistedProperties: [],
+								specialProperties: {},
+								specialQueries: {}
+							},
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "YOUTUBE_GET_API_REQUESTS", `Failed to get YouTube api requests. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "YOUTUBE_GET_API_REQUESTS", `Fetched YouTube api requests successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully fetched YouTube api requests.",
+						data: response
+					});
 				}
-				this.log("SUCCESS", "YOUTUBE_GET_API_REQUESTS", `Fetched YouTube api requests successfully.`);
-				return cb({
-					status: "success",
-					message: "Successfully fetched YouTube api requests.",
-					data: response
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Returns a specific api request
 	 *
 	 * @returns {{status: string, data: object}}
 	 */
-	getApiRequest: isAdminRequired(function getApiRequest(session, apiRequestId, cb) {
+	getApiRequest: useHasPermission("youtube.getApiRequest", function getApiRequest(session, apiRequestId, cb) {
 		if (!mongoose.Types.ObjectId.isValid(apiRequestId))
 			return cb({ status: "error", message: "Api request id is not a valid ObjectId." });
 
@@ -164,78 +155,84 @@ export default {
 	 *
 	 * @returns {{status: string, data: object}}
 	 */
-	resetStoredApiRequests: isAdminRequired(async function resetStoredApiRequests(session, cb) {
-		this.keepLongJob();
-		this.publishProgress({
-			status: "started",
-			title: "Reset stored API requests",
-			message: "Resetting stored API requests.",
-			id: this.toString()
-		});
-		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
-		await CacheModule.runJob(
-			"PUB",
-			{
-				channel: "longJob.added",
-				value: { jobId: this.toString(), userId: session.userId }
-			},
-			this
-		);
+	resetStoredApiRequests: useHasPermission(
+		"youtube.resetStoredApiRequests",
+		async function resetStoredApiRequests(session, cb) {
+			this.keepLongJob();
+			this.publishProgress({
+				status: "started",
+				title: "Reset stored API requests",
+				message: "Resetting stored API requests.",
+				id: this.toString()
+			});
+			await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+			await CacheModule.runJob(
+				"PUB",
+				{
+					channel: "longJob.added",
+					value: { jobId: this.toString(), userId: session.userId }
+				},
+				this
+			);
 
-		YouTubeModule.runJob("RESET_STORED_API_REQUESTS", {}, this)
-			.then(() => {
-				this.log(
-					"SUCCESS",
-					"YOUTUBE_RESET_STORED_API_REQUESTS",
-					`Resetting stored API requests was successful.`
-				);
-				this.publishProgress({
-					status: "success",
-					message: "Successfully reset stored YouTube API requests."
-				});
-				return cb({ status: "success", message: "Successfully reset stored YouTube API requests" });
-			})
-			.catch(async err => {
-				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-				this.log(
-					"ERROR",
-					"YOUTUBE_RESET_STORED_API_REQUESTS",
-					`Resetting stored API requests failed. "${err}"`
-				);
-				this.publishProgress({
-					status: "error",
-					message: err
+			YouTubeModule.runJob("RESET_STORED_API_REQUESTS", {}, this)
+				.then(() => {
+					this.log(
+						"SUCCESS",
+						"YOUTUBE_RESET_STORED_API_REQUESTS",
+						`Resetting stored API requests was successful.`
+					);
+					this.publishProgress({
+						status: "success",
+						message: "Successfully reset stored YouTube API requests."
+					});
+					return cb({ status: "success", message: "Successfully reset stored YouTube API requests" });
+				})
+				.catch(async err => {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"YOUTUBE_RESET_STORED_API_REQUESTS",
+						`Resetting stored API requests failed. "${err}"`
+					);
+					this.publishProgress({
+						status: "error",
+						message: err
+					});
+					return cb({ status: "error", message: err });
 				});
-				return cb({ status: "error", message: err });
-			});
-	}),
+		}
+	),
 
 	/**
 	 * Remove stored API requests
 	 *
 	 * @returns {{status: string, data: object}}
 	 */
-	removeStoredApiRequest: isAdminRequired(function removeStoredApiRequest(session, requestId, cb) {
-		YouTubeModule.runJob("REMOVE_STORED_API_REQUEST", { requestId }, this)
-			.then(() => {
-				this.log(
-					"SUCCESS",
-					"YOUTUBE_REMOVE_STORED_API_REQUEST",
-					`Removing stored API request "${requestId}" was successful.`
-				);
+	removeStoredApiRequest: useHasPermission(
+		"youtube.removeStoredApiRequest",
+		function removeStoredApiRequest(session, requestId, cb) {
+			YouTubeModule.runJob("REMOVE_STORED_API_REQUEST", { requestId }, this)
+				.then(() => {
+					this.log(
+						"SUCCESS",
+						"YOUTUBE_REMOVE_STORED_API_REQUEST",
+						`Removing stored API request "${requestId}" was successful.`
+					);
 
-				return cb({ status: "success", message: "Successfully removed stored YouTube API request" });
-			})
-			.catch(async err => {
-				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-				this.log(
-					"ERROR",
-					"YOUTUBE_REMOVE_STORED_API_REQUEST",
-					`Removing stored API request "${requestId}" failed. "${err}"`
-				);
-				return cb({ status: "error", message: err });
-			});
-	}),
+					return cb({ status: "success", message: "Successfully removed stored YouTube API request" });
+				})
+				.catch(async err => {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"YOUTUBE_REMOVE_STORED_API_REQUEST",
+						`Removing stored API request "${requestId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				});
+		}
+	),
 
 	/**
 	 * Gets videos, used in the admin youtube page by the AdvancedTable component
@@ -249,135 +246,129 @@ export default {
 	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getVideos: isAdminRequired(async function getVideos(
-		session,
-		page,
-		pageSize,
-		properties,
-		sort,
-		queries,
-		operator,
-		cb
-	) {
-		async.waterfall(
-			[
-				next => {
-					DBModule.runJob(
-						"GET_DATA",
-						{
-							page,
-							pageSize,
-							properties,
-							sort,
-							queries,
-							operator,
-							modelName: "youtubeVideo",
-							blacklistedProperties: [],
-							specialProperties: {
-								songId: [
-									// Fetch songs from songs collection with a matching youtubeId
-									{
-										$lookup: {
-											from: "songs",
-											localField: "youtubeId",
-											foreignField: "youtubeId",
-											as: "song"
-										}
-									},
-									// Turn the array of songs returned in the last step into one object, since only one song should have been returned maximum
-									{
-										$unwind: {
-											path: "$song",
-											preserveNullAndEmptyArrays: true
-										}
-									},
-									// Add new field songId, which grabs the song object's _id and tries turning it into a string
-									{
-										$addFields: {
-											songId: {
-												$convert: {
-													input: "$song._id",
-													to: "string",
-													onError: "",
-													onNull: ""
+	getVideos: useHasPermission(
+		"admin.view.youtubeVideos",
+		async function getVideos(session, page, pageSize, properties, sort, queries, operator, cb) {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob(
+							"GET_DATA",
+							{
+								page,
+								pageSize,
+								properties,
+								sort,
+								queries,
+								operator,
+								modelName: "youtubeVideo",
+								blacklistedProperties: [],
+								specialProperties: {
+									songId: [
+										// Fetch songs from songs collection with a matching youtubeId
+										{
+											$lookup: {
+												from: "songs",
+												localField: "youtubeId",
+												foreignField: "youtubeId",
+												as: "song"
+											}
+										},
+										// Turn the array of songs returned in the last step into one object, since only one song should have been returned maximum
+										{
+											$unwind: {
+												path: "$song",
+												preserveNullAndEmptyArrays: true
+											}
+										},
+										// Add new field songId, which grabs the song object's _id and tries turning it into a string
+										{
+											$addFields: {
+												songId: {
+													$convert: {
+														input: "$song._id",
+														to: "string",
+														onError: "",
+														onNull: ""
+													}
 												}
 											}
+										},
+										// Cleanup, don't return the song object for any further steps
+										{
+											$project: {
+												song: 0
+											}
 										}
-									},
-									// Cleanup, don't return the song object for any further steps
-									{
-										$project: {
-											song: 0
-										}
-									}
-								]
-							},
-							specialQueries: {},
-							specialFilters: {
-								importJob: importJobId => [
-									{
-										$lookup: {
-											from: "importjobs",
-											let: { youtubeId: "$youtubeId" },
-											pipeline: [
-												{
-													$match: {
-														_id: mongoose.Types.ObjectId(importJobId)
-													}
-												},
-												{
-													$addFields: {
-														importJob: {
-															$in: ["$$youtubeId", "$response.successfulVideoIds"]
+									]
+								},
+								specialQueries: {},
+								specialFilters: {
+									importJob: importJobId => [
+										{
+											$lookup: {
+												from: "importjobs",
+												let: { youtubeId: "$youtubeId" },
+												pipeline: [
+													{
+														$match: {
+															_id: mongoose.Types.ObjectId(importJobId)
+														}
+													},
+													{
+														$addFields: {
+															importJob: {
+																$in: ["$$youtubeId", "$response.successfulVideoIds"]
+															}
+														}
+													},
+													{
+														$project: {
+															importJob: 1,
+															_id: 0
 														}
 													}
-												},
-												{
-													$project: {
-														importJob: 1,
-														_id: 0
-													}
-												}
-											],
-											as: "importJob"
-										}
-									},
-									{
-										$unwind: "$importJob"
-									},
-									{
-										$set: {
-											importJob: "$importJob.importJob"
+												],
+												as: "importJob"
+											}
+										},
+										{
+											$unwind: "$importJob"
+										},
+										{
+											$set: {
+												importJob: "$importJob.importJob"
+											}
 										}
-									}
-								]
-							}
-						},
-						this
-					)
-						.then(response => {
-							next(null, response);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, response) => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "YOUTUBE_GET_VIDEOS", `Failed to get YouTube videos. "${err}"`);
-					return cb({ status: "error", message: err });
+									]
+								}
+							},
+							this
+						)
+							.then(response => {
+								next(null, response);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				async (err, response) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log("ERROR", "YOUTUBE_GET_VIDEOS", `Failed to get YouTube videos. "${err}"`);
+						return cb({ status: "error", message: err });
+					}
+					this.log("SUCCESS", "YOUTUBE_GET_VIDEOS", `Fetched YouTube videos successfully.`);
+					return cb({
+						status: "success",
+						message: "Successfully fetched YouTube videos.",
+						data: response
+					});
 				}
-				this.log("SUCCESS", "YOUTUBE_GET_VIDEOS", `Fetched YouTube videos successfully.`);
-				return cb({
-					status: "success",
-					message: "Successfully fetched YouTube videos.",
-					data: response
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Get a YouTube video
@@ -403,7 +394,7 @@ export default {
 	 *
 	 * @returns {{status: string, data: object}}
 	 */
-	removeVideos: isAdminRequired(async function removeVideos(session, videoIds, cb) {
+	removeVideos: useHasPermission("youtube.removeVideos", async function removeVideos(session, videoIds, cb) {
 		this.keepLongJob();
 		this.publishProgress({
 			status: "started",
@@ -484,110 +475,113 @@ export default {
 	 * @param {boolean} musicOnly - whether to return videos
 	 * @param {Function} cb - gets called with the result
 	 */
-	requestSetAdmin: isAdminRequired(async function requestSetAdmin(session, url, musicOnly, returnVideos, cb) {
-		const importJobModel = await DBModule.runJob("GET_MODEL", { modelName: "importJob" }, this);
+	requestSetAdmin: useHasPermission(
+		"youtube.requestSetAdmin",
+		async function requestSetAdmin(session, url, musicOnly, returnVideos, cb) {
+			const importJobModel = await DBModule.runJob("GET_MODEL", { modelName: "importJob" }, this);
 
-		this.keepLongJob();
-		this.publishProgress({
-			status: "started",
-			title: "Import playlist",
-			message: "Importing playlist.",
-			id: this.toString()
-		});
-		await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
-		await CacheModule.runJob(
-			"PUB",
-			{
-				channel: "longJob.added",
-				value: { jobId: this.toString(), userId: session.userId }
-			},
-			this
-		);
+			this.keepLongJob();
+			this.publishProgress({
+				status: "started",
+				title: "Import playlist",
+				message: "Importing playlist.",
+				id: this.toString()
+			});
+			await CacheModule.runJob("RPUSH", { key: `longJobs.${session.userId}`, value: this.toString() }, this);
+			await CacheModule.runJob(
+				"PUB",
+				{
+					channel: "longJob.added",
+					value: { jobId: this.toString(), userId: session.userId }
+				},
+				this
+			);
 
-		async.waterfall(
-			[
-				next => {
-					importJobModel.create(
-						{
-							type: "youtube",
-							query: {
-								url,
-								musicOnly
+			async.waterfall(
+				[
+					next => {
+						importJobModel.create(
+							{
+								type: "youtube",
+								query: {
+									url,
+									musicOnly
+								},
+								status: "in-progress",
+								response: {},
+								requestedBy: session.userId,
+								requestedAt: Date.now()
 							},
-							status: "in-progress",
-							response: {},
-							requestedBy: session.userId,
-							requestedAt: Date.now()
-						},
-						next
-					);
-				},
+							next
+						);
+					},
 
-				(importJob, next) => {
-					YouTubeModule.runJob("REQUEST_SET", { url, musicOnly, returnVideos }, this)
-						.then(response => {
-							next(null, importJob, response);
-						})
-						.catch(err => {
-							next(err, importJob);
-						});
-				},
+					(importJob, next) => {
+						YouTubeModule.runJob("REQUEST_SET", { url, musicOnly, returnVideos }, this)
+							.then(response => {
+								next(null, importJob, response);
+							})
+							.catch(err => {
+								next(err, importJob);
+							});
+					},
 
-				(importJob, response, next) => {
-					importJobModel.updateOne(
-						{ _id: importJob._id },
-						{
-							$set: {
-								status: "success",
-								response: {
-									failed: response.failed,
-									successful: response.successful,
-									alreadyInDatabase: response.alreadyInDatabase,
-									successfulVideoIds: response.successfulVideoIds,
-									failedVideoIds: response.failedVideoIds
+					(importJob, response, next) => {
+						importJobModel.updateOne(
+							{ _id: importJob._id },
+							{
+								$set: {
+									status: "success",
+									response: {
+										failed: response.failed,
+										successful: response.successful,
+										alreadyInDatabase: response.alreadyInDatabase,
+										successfulVideoIds: response.successfulVideoIds,
+										failedVideoIds: response.failedVideoIds
+									}
 								}
+							},
+							err => {
+								if (err) next(err, importJob);
+								else
+									MediaModule.runJob("UPDATE_IMPORT_JOBS", { jobIds: importJob._id })
+										.then(() => next(null, importJob, response))
+										.catch(error => next(error, importJob));
 							}
-						},
-						err => {
-							if (err) next(err, importJob);
-							else
-								MediaModule.runJob("UPDATE_IMPORT_JOBS", { jobIds: importJob._id })
-									.then(() => next(null, importJob, response))
-									.catch(error => next(error, importJob));
-						}
-					);
-				}
-			],
-			async (err, importJob, response) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						);
+					}
+				],
+				async (err, importJob, response) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						this.log(
+							"ERROR",
+							"REQUEST_SET_ADMIN",
+							`Importing a YouTube playlist to be requested failed for admin "${session.userId}". "${err}"`
+						);
+						importJobModel.updateOne({ _id: importJob._id }, { $set: { status: "error" } });
+						MediaModule.runJob("UPDATE_IMPORT_JOBS", { jobIds: importJob._id });
+						return cb({ status: "error", message: err });
+					}
+
 					this.log(
-						"ERROR",
+						"SUCCESS",
 						"REQUEST_SET_ADMIN",
-						`Importing a YouTube playlist to be requested failed for admin "${session.userId}". "${err}"`
+						`Successfully imported a YouTube playlist to be requested for admin "${session.userId}".`
 					);
-					importJobModel.updateOne({ _id: importJob._id }, { $set: { status: "error" } });
-					MediaModule.runJob("UPDATE_IMPORT_JOBS", { jobIds: importJob._id });
-					return cb({ status: "error", message: err });
-				}
 
-				this.log(
-					"SUCCESS",
-					"REQUEST_SET_ADMIN",
-					`Successfully imported a YouTube playlist to be requested for admin "${session.userId}".`
-				);
+					this.publishProgress({
+						status: "success",
+						message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`
+					});
 
-				this.publishProgress({
-					status: "success",
-					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`
-				});
-
-				return cb({
-					status: "success",
-					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`,
-					videos: returnVideos ? response.videos : null
-				});
-			}
-		);
-	})
+					return cb({
+						status: "success",
+						message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`,
+						videos: returnVideos ? response.videos : null
+					});
+				}
+			);
+		}
+	)
 };

+ 11 - 14
backend/logic/api.js

@@ -5,6 +5,8 @@ import crypto from "crypto";
 
 import CoreClass from "../core";
 
+import { hasPermission } from "./hooks/hasPermission";
+
 let AppModule;
 let DBModule;
 let PlaylistsModule;
@@ -127,29 +129,24 @@ class _APIModule extends CoreClass {
 					response.app.get("/export/playlist/:playlistId", async (req, res) => {
 						const { playlistId } = req.params;
 
-						const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
-
 						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId })
 							.then(playlist => {
-								if (playlist.privacy === "public") res.json({ status: "success", playlist });
-								else {
+								if (!playlist) res.json({ status: "error", message: "Playlist not found." });
+								else if (playlist.privacy === "public") res.json({ status: "success", playlist });
+								else
 									isLoggedIn(req, res, () => {
 										if (playlist.createdBy === req.session.userId)
 											res.json({ status: "success", playlist });
-										else {
-											userModel.findOne({ _id: req.session.userId }, (err, user) => {
-												if (err) res.json({ status: "error", message: err.message });
-												else if (user.role === "admin")
-													res.json({ status: "success", playlist });
-												else
+										else
+											hasPermission("playlists.get", req.session.userId)
+												.then(() => res.json({ status: "success", playlist }))
+												.catch(() =>
 													res.json({
 														status: "error",
 														message: "You're not allowed to download this playlist."
-													});
-											});
-										}
+													})
+												);
 									});
-								}
 							})
 							.catch(err => {
 								res.json({ status: "error", message: err.message });

+ 282 - 0
backend/logic/hooks/hasPermission.js

@@ -0,0 +1,282 @@
+import async from "async";
+
+// eslint-disable-next-line
+import moduleManager from "../../index";
+
+const permissions = {};
+permissions.dj = {
+	"stations.autofill": true,
+	"stations.blacklist": true,
+	"stations.index": true,
+	"stations.playback.toggle": true,
+	"stations.queue.remove": true,
+	"stations.queue.reposition": true,
+	"stations.queue.reset": true,
+	"stations.request": true,
+	"stations.skip": true,
+	"stations.view": true,
+	"stations.view.manage": true
+};
+permissions.owner = {
+	...permissions.dj,
+	"stations.remove": true,
+	"stations.update": true
+};
+permissions.moderator = {
+	...permissions.owner,
+	"admin.view": true,
+	"admin.view.import": true,
+	"admin.view.news": true,
+	"admin.view.playlists": true,
+	"admin.view.punishments": true,
+	"admin.view.reports": true,
+	"admin.view.songs": true,
+	"admin.view.stations": true,
+	"admin.view.youtubeVideos": true,
+	"apis.searchDiscogs": true,
+	"news.create": true,
+	"news.update": true,
+	"playlists.get": true,
+	"playlists.update.displayName": false,
+	"playlists.update.privacy": true,
+	"playlists.songs.add": true,
+	"playlists.songs.remove": true,
+	"playlists.songs.reposition": true,
+	"playlists.view.others": true,
+	"punishments.banIP": true,
+	"punishments.get": true,
+	"reports.get": true,
+	"reports.update": true,
+	"songs.create": true,
+	"songs.get": true,
+	"songs.update": true,
+	"songs.verify": true,
+	"stations.create.official": true,
+	"stations.index": false,
+	"stations.index.other": true,
+	"stations.remove": false,
+	"youtube.requestSetAdmin": true
+};
+permissions.admin = {
+	...permissions.moderator,
+	"admin.view.statistics": true,
+	"admin.view.users": true,
+	"admin.view.youtube": true,
+	"dataRequests.resolve": true,
+	"media.recalculateAllRatings": true,
+	"media.removeImportJobs": true,
+	"news.remove": true,
+	"playlists.clearAndRefill": true,
+	"playlists.clearAndRefillAll": true,
+	"playlists.createMissing": true,
+	"playlists.deleteOrphaned": true,
+	"playlists.removeAdmin": true,
+	"playlists.requestOrphanedPlaylistSongs": true,
+	"punishments.deactivate": true,
+	"reports.remove": true,
+	"songs.remove": true,
+	"songs.updateAll": true,
+	"stations.clearEveryStationQueue": true,
+	"stations.remove": true,
+	"users.get": true,
+	"users.update": true,
+	"users.remove": true,
+	"users.remove.sessions": true,
+	"users.requestPasswordReset": true,
+	"users.resendVerifyEmail": true,
+	"users.ban": true,
+	"utils.getModules": true,
+	"youtube.getApiRequest": true,
+	"youtube.resetStoredApiRequests": true,
+	"youtube.removeStoredApiRequest": true,
+	"youtube.removeVideos": true
+};
+
+export const hasPermission = async (permission, session, stationId) => {
+	const CacheModule = moduleManager.modules.cache;
+	const DBModule = moduleManager.modules.db;
+	const StationsModule = moduleManager.modules.stations;
+	const UtilsModule = moduleManager.modules.utils;
+	const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					let userId;
+					if (typeof session === "object") {
+						if (session.userId) userId = session.userId;
+						else
+							CacheModule.runJob(
+								"HGET",
+								{
+									table: "sessions",
+									key: session.sessionId
+								},
+								this
+							)
+								.then(_session => {
+									if (_session && _session.userId) userId = _session.userId;
+								})
+								.catch(next);
+					} else userId = session;
+					if (!userId) return next("User ID required.");
+					return userModel.findOne({ _id: userId }, next);
+				},
+				(user, next) => {
+					if (!user) return next("Login required.");
+					if (!stationId) return next(null, [user.role]);
+					return StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							if (!station) return next("Station not found.");
+							if (station.type === "community" && station.owner === user._id.toString())
+								return next(null, [user.role, "owner"]);
+							// if (station.type === "community" && station.djs.find(userId))
+							// 	return next(null, [user.role, "dj"]);
+							if (user.role === "admin" || user.role === "moderator") return next(null, [user.role]);
+							return next("Invalid permissions.");
+						})
+						.catch(next);
+				},
+				(roles, next) => {
+					if (!roles) return next("Role required.");
+					let permissionFound;
+					roles.forEach(role => {
+						if (permissions[role] && permissions[role][permission]) permissionFound = true;
+					});
+					if (permissionFound) return next();
+					return next("Insufficient permissions.");
+				}
+			],
+			async err => {
+				const userId = typeof session === "object" ? session.userId || session.sessionId : session;
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					UtilsModule.log(
+						"INFO",
+						"HAS_PERMISSION",
+						`User "${userId}" does not have required permission "${permission}". "${err}"`
+					);
+					return reject(err);
+				}
+				UtilsModule.log(
+					"INFO",
+					"HAS_PERMISSION",
+					`User "${userId}" has required permission "${permission}".`,
+					false
+				);
+				return resolve();
+			}
+		);
+	});
+};
+
+export const useHasPermission = (options, destination) =>
+	async function useHasPermission(session, ...args) {
+		const UtilsModule = moduleManager.modules.utils;
+		const permission = typeof options === "object" ? options.permission : options;
+		const stationId = typeof options === "object" ? options.stationId : null;
+		const cb = args[args.length - 1];
+
+		async.waterfall(
+			[
+				next => {
+					if (!session || !session.sessionId) return next("Login required.");
+					return hasPermission(permission, session, stationId)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"INFO",
+						"USE_HAS_PERMISSION",
+						`User "${session.userId}" does not have required permission "${permission}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"INFO",
+					"USE_HAS_PERMISSION",
+					`User "${session.userId}" has required permission "${permission}".`,
+					false
+				);
+				return destination.apply(this, [session].concat(args));
+			}
+		);
+	};
+
+export const getUserPermissions = async (session, stationId) => {
+	const CacheModule = moduleManager.modules.cache;
+	const DBModule = moduleManager.modules.db;
+	const StationsModule = moduleManager.modules.stations;
+	const UtilsModule = moduleManager.modules.utils;
+	const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					let userId;
+					if (typeof session === "object") {
+						if (session.userId) userId = session.userId;
+						else
+							CacheModule.runJob(
+								"HGET",
+								{
+									table: "sessions",
+									key: session.sessionId
+								},
+								this
+							)
+								.then(_session => {
+									if (_session && _session.userId) userId = _session.userId;
+								})
+								.catch(next);
+					} else userId = session;
+					if (!userId) return next("User ID required.");
+					return userModel.findOne({ _id: userId }, next);
+				},
+				(user, next) => {
+					if (!user) return next("Login required.");
+					if (!stationId) return next(null, [user.role]);
+					return StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							if (!station) return next("Station not found.");
+							if (station.type === "community" && station.owner === user._id.toString())
+								return next(null, [user.role, "owner"]);
+							// if (station.type === "community" && station.djs.find(userId))
+							// 	return next(null, [user.role, "dj"]);
+							if (user.role === "admin" || user.role === "moderator") return next(null, [user.role]);
+							return next("Invalid permissions.");
+						})
+						.catch(next);
+				},
+				(roles, next) => {
+					if (!roles) return next("Role required.");
+					let rolePermissions = {};
+					roles.forEach(role => {
+						if (permissions[role]) rolePermissions = { ...rolePermissions, ...permissions[role] };
+					});
+					return next(null, rolePermissions);
+				}
+			],
+			async (err, rolePermissions) => {
+				const userId = typeof session === "object" ? session.userId || session.sessionId : session;
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					UtilsModule.log(
+						"INFO",
+						"GET_USER_PERMISSIONS",
+						`Failed to get permissions for user "${userId}". "${err}"`
+					);
+					return reject(err);
+				}
+				UtilsModule.log("INFO", "GET_USER_PERMISSIONS", `Fetched permissions for user "${userId}".`, false);
+				return resolve(rolePermissions);
+			}
+		);
+	});
+};

+ 1 - 1
backend/logic/actions/hooks/loginRequired.js → backend/logic/hooks/loginRequired.js

@@ -1,7 +1,7 @@
 import async from "async";
 
 // eslint-disable-next-line
-import moduleManager from "../../../index";
+import moduleManager from "../../index";
 
 const CacheModule = moduleManager.modules.cache;
 const UtilsModule = moduleManager.modules.utils;

+ 27 - 100
backend/logic/stations.js

@@ -2,6 +2,8 @@ import async from "async";
 
 import CoreClass from "../core";
 
+import { hasPermission } from "./hooks/hasPermission";
+
 let StationsModule;
 let CacheModule;
 let DBModule;
@@ -1006,31 +1008,16 @@ class _StationsModule extends CoreClass {
 							if (session.sessionId) {
 								CacheModule.runJob("HGET", { table: "sessions", key: session.sessionId }).then(
 									session => {
-										if (session) {
-											DBModule.runJob("GET_MODEL", { modelName: "user" }).then(userModel => {
-												userModel.findOne({ _id: session.userId }, (err, user) => {
-													if (!err && user) {
-														if (user.role === "admin")
-															socket.dispatch("event:station.nextSong", {
-																data: {
-																	stationId: station._id,
-																	currentSong
-																}
-															});
-														else if (
-															station.type === "community" &&
-															station.owner === session.userId
-														)
-															socket.dispatch("event:station.nextSong", {
-																data: {
-																	stationId: station._id,
-																	currentSong
-																}
-															});
+										hasPermission("stations.skip", session, station._id)
+											.then(() =>
+												socket.dispatch("event:station.nextSong", {
+													data: {
+														stationId: station._id,
+														currentSong
 													}
-												});
-											});
-										}
+												})
+											)
+											.catch(() => {});
 									}
 								);
 							}
@@ -1080,17 +1067,9 @@ class _StationsModule extends CoreClass {
 					},
 
 					next => {
-						DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
-							userModel.findOne({ _id: payload.userId }, next);
-						});
-					},
-
-					(user, next) => {
-						if (!user) return next("Not allowed");
-						if (user.role === "admin" || payload.station.owner === payload.userId) return next(true);
-						if (payload.station.type === "official") return next("Not allowed");
-
-						return next("Not allowed");
+						hasPermission("stations.view", payload.userId, payload.station._id)
+							.then(() => next(true))
+							.catch(() => next("Not allowed"));
 					}
 				],
 				async errOrResult => {
@@ -1187,71 +1166,19 @@ class _StationsModule extends CoreClass {
 										sockets,
 										1,
 										(socket, next) => {
-											const { session } = socket;
-
-											async.waterfall(
-												[
-													next => {
-														if (!session.sessionId) next("No session id");
-														else next();
-													},
-
-													next => {
-														CacheModule.runJob(
-															"HGET",
-															{
-																table: "sessions",
-																key: session.sessionId
-															},
-															this
-														)
-															.then(response => {
-																next(null, response);
-															})
-															.catch(next);
-													},
-
-													(session, next) => {
-														if (!session) next("No session");
-														else {
-															DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
-																.then(userModel => {
-																	next(null, userModel);
-																})
-																.catch(next);
-														}
-													},
-
-													(userModel, next) => {
-														if (!userModel) next("No user model");
-														else
-															userModel.findOne(
-																{
-																	_id: session.userId
-																},
-																next
-															);
-													},
-
-													(user, next) => {
-														if (!user) next("No user found");
-														else if (user.role === "admin") {
-															socketsThatCan.push(socket);
-															next();
-														} else if (
-															payload.station.type === "community" &&
-															payload.station.owner === session.userId
-														) {
-															socketsThatCan.push(socket);
-															next();
-														}
-													}
-												],
-												err => {
-													if (err) socketsThatCannot.push(socket);
-													next();
-												}
-											);
+											if (!(socket.session && socket.session.sessionId)) {
+												socketsThatCannot.push(socket);
+												next();
+											} else
+												hasPermission("stations.view", socket.session, payload.station._id)
+													.then(() => {
+														socketsThatCan.push(socket);
+														next();
+													})
+													.catch(() => {
+														socketsThatCannot.push(socket);
+														next();
+													});
 										},
 										err => {
 											if (err) reject(err);

+ 13 - 3
backend/logic/ws.js

@@ -9,6 +9,8 @@ import { EventEmitter } from "events";
 
 import CoreClass from "../core";
 
+import { getUserPermissions } from "./hooks/hasPermission";
+
 let WSModule;
 let AppModule;
 let CacheModule;
@@ -608,9 +610,17 @@ class _WSModule extends CoreClass {
 									userId = session.userId;
 								}
 
-								return socket.dispatch("ready", {
-									data: { loggedIn: true, role, username, userId, email }
-								});
+								return getUserPermissions(session.userId)
+									.then(permissions =>
+										socket.dispatch("ready", {
+											data: { loggedIn: true, role, username, userId, email, permissions }
+										})
+									)
+									.catch(() =>
+										socket.dispatch("ready", {
+											data: { loggedIn: true, role, username, userId, email }
+										})
+									);
 							});
 						} else socket.dispatch("ready", { data: { loggedIn: false } });
 					})

+ 3 - 3
frontend/src/components/MainHeader.vue

@@ -32,8 +32,8 @@ const windowWidth = ref(0);
 
 const { socket } = useWebsocketsStore();
 
-const { loggedIn, username, role } = storeToRefs(userAuthStore);
-const { logout } = userAuthStore;
+const { loggedIn, username } = storeToRefs(userAuthStore);
+const { logout, hasPermission } = userAuthStore;
 const { changeNightmode } = useUserPreferencesStore();
 
 const { openModal } = useModalsStore();
@@ -129,7 +129,7 @@ onMounted(async () => {
 			</div>
 			<span v-if="loggedIn" class="grouped">
 				<router-link
-					v-if="role === 'admin'"
+					v-if="hasPermission('admin.view')"
 					class="nav-item admin"
 					to="/admin"
 				>

+ 10 - 12
frontend/src/components/PlaylistTabBase.vue

@@ -6,7 +6,6 @@ import ws from "@/ws";
 
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useStationStore } from "@/stores/station";
-import { useUserAuthStore } from "@/stores/userAuth";
 import { useUserPlaylistsStore } from "@/stores/userPlaylists";
 import { useModalsStore } from "@/stores/modals";
 import { useManageStationStore } from "@/stores/manageStation";
@@ -36,7 +35,6 @@ const emit = defineEmits(["selected"]);
 
 const { socket } = useWebsocketsStore();
 const stationStore = useStationStore();
-const userAuthStore = useUserAuthStore();
 
 const tab = ref("current");
 const search = reactive({
@@ -62,7 +60,6 @@ const {
 	calculatePlaylistOrder
 } = useSortablePlaylists();
 
-const { loggedIn, role, userId } = storeToRefs(userAuthStore);
 const { autoRequest } = storeToRefs(stationStore);
 
 const manageStationStore = useManageStationStore(props);
@@ -99,6 +96,11 @@ const nextPageResultsCount = computed(() =>
 	Math.min(search.pageSize, resultsLeftCount.value)
 );
 
+const hasPermission = permission =>
+	props.sector === "manageStation"
+		? manageStationStore.hasPermission(permission)
+		: stationStore.hasPermission(permission);
+
 const { openModal } = useModalsStore();
 
 const { setPlaylists } = useUserPlaylistsStore();
@@ -144,11 +146,6 @@ const showTab = _tab => {
 	tab.value = _tab;
 };
 
-const isOwner = () =>
-	loggedIn.value && station.value && userId.value === station.value.owner;
-const isAdmin = () => loggedIn.value && role.value === "admin";
-const isOwnerOrAdmin = () => isOwner() || isAdmin();
-
 const label = (tense = "future", typeOverwrite = null, capitalize = false) => {
 	let label = typeOverwrite || props.type;
 
@@ -513,7 +510,7 @@ onMounted(() => {
 								v-if="
 									featuredPlaylist.createdBy !== myUserId &&
 									(featuredPlaylist.privacy === 'public' ||
-										isAdmin())
+										hasPermission('playlists.view.others'))
 								"
 								@click="
 									openModal({
@@ -694,7 +691,8 @@ onMounted(() => {
 							<i
 								v-if="
 									playlist.createdBy !== myUserId &&
-									(playlist.privacy === 'public' || isAdmin())
+									(playlist.privacy === 'public' ||
+										hasPermission('playlists.view.others'))
 								"
 								@click="
 									openModal({
@@ -747,7 +745,6 @@ onMounted(() => {
 
 						<template #actions>
 							<quick-confirm
-								v-if="isOwnerOrAdmin()"
 								@confirm="deselectPlaylist(playlist._id)"
 							>
 								<i
@@ -776,7 +773,8 @@ onMounted(() => {
 							<i
 								v-if="
 									playlist.createdBy !== myUserId &&
-									(playlist.privacy === 'public' || isAdmin())
+									(playlist.privacy === 'public' ||
+										hasPermission('playlists.view.others'))
 								"
 								@click="
 									openModal({

+ 10 - 13
frontend/src/components/Queue.vue

@@ -2,10 +2,8 @@
 import { defineAsyncComponent, ref, computed, onUpdated } from "vue";
 import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
-import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useStationStore } from "@/stores/station";
-import { useUserAuthStore } from "@/stores/userAuth";
 import { useManageStationStore } from "@/stores/manageStation";
 
 const SongItem = defineAsyncComponent(
@@ -22,11 +20,8 @@ const props = defineProps({
 
 const { socket } = useWebsocketsStore();
 const stationStore = useStationStore();
-const userAuthStore = useUserAuthStore();
 const manageStationStore = useManageStationStore(props);
 
-const { loggedIn, userId, role: userRole } = storeToRefs(userAuthStore);
-
 const repositionSongInList = payload => {
 	if (props.sector === "manageStation")
 		return manageStationStore.repositionSongInList(payload);
@@ -62,15 +57,15 @@ const queue = computed({
 	}
 });
 
-const isOwnerOnly = () =>
-	loggedIn.value && userId.value === station.value.owner;
-
-const isAdminOnly = () => loggedIn.value && userRole.value === "admin";
+const hasPermission = permission =>
+	props.sector === "manageStation"
+		? manageStationStore.hasPermission(permission)
+		: stationStore.hasPermission(permission);
 
 const dragOptions = computed(() => ({
 	animation: 200,
 	group: "queue",
-	disabled: !(isAdminOnly() || isOwnerOnly()),
+	disabled: !hasPermission("stations.queue.reposition"),
 	ghostClass: "draggable-list-ghost"
 }));
 
@@ -162,17 +157,19 @@ onUpdated(() => {
 						:song="element"
 						:requested-by="true"
 						:class="{
-							'item-draggable': isAdminOnly() || isOwnerOnly()
+							'item-draggable': hasPermission(
+								'stations.queue.reposition'
+							)
 						}"
 						:disabled-actions="[]"
 						:ref="el => (songItems[`song-item-${index}`] = el)"
 					>
 						<template
-							v-if="isAdminOnly() || isOwnerOnly()"
+							v-if="hasPermission('stations.queue.reposition')"
 							#tippyActions
 						>
 							<quick-confirm
-								v-if="isOwnerOnly() || isAdminOnly()"
+								v-if="hasPermission('stations.queue.remove')"
 								placement="left"
 								@confirm="removeFromQueue(element.youtubeId)"
 							>

+ 3 - 2
frontend/src/components/SongItem.vue

@@ -47,7 +47,8 @@ const hoveredTippy = ref(false);
 const songActions = ref(null);
 
 const userAuthStore = useUserAuthStore();
-const { loggedIn, role: userRole } = storeToRefs(userAuthStore);
+const { loggedIn } = storeToRefs(userAuthStore);
+const { hasPermission } = userAuthStore;
 
 const { openModal } = useModalsStore();
 
@@ -246,7 +247,7 @@ onUnmounted(() => {
 								v-if="
 									loggedIn &&
 									song._id &&
-									userRole === 'admin' &&
+									hasPermission('songs.update') &&
 									disabledActions.indexOf('edit') === -1
 								"
 								class="material-icons edit-icon"

+ 24 - 14
frontend/src/components/StationInfoBox.vue

@@ -4,27 +4,31 @@ import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useModalsStore } from "@/stores/modals";
-
-const userAuthStore = useUserAuthStore();
+import { useStationStore } from "@/stores/station";
+import { useManageStationStore } from "@/stores/manageStation";
 
 const props = defineProps({
 	station: { type: Object, default: null },
 	stationPaused: { type: Boolean, default: null },
 	showManageStation: { type: Boolean, default: false },
-	showGoToStation: { type: Boolean, default: false }
+	showGoToStation: { type: Boolean, default: false },
+	modalUuid: { type: String, default: "" },
+	sector: { type: String, default: "station" }
 });
 
+const userAuthStore = useUserAuthStore();
+const stationStore = useStationStore();
+const manageStationStore = useManageStationStore(props);
+
 const { socket } = useWebsocketsStore();
-const { loggedIn, userId, role } = storeToRefs(userAuthStore);
+const { loggedIn } = storeToRefs(userAuthStore);
 
 const { openModal } = useModalsStore();
 
-const isOwnerOnly = () =>
-	loggedIn.value && userId.value === props.station.owner;
-
-const isAdminOnly = () => loggedIn.value && role.value === "admin";
-
-const isOwnerOrAdmin = () => isOwnerOnly() || isAdminOnly();
+const hasPermission = permission =>
+	props.sector === "manageStation"
+		? manageStationStore.hasPermission(permission)
+		: stationStore.hasPermission(permission);
 
 const resumeStation = () => {
 	socket.dispatch("stations.resume", props.station._id, data => {
@@ -104,7 +108,9 @@ const unfavoriteStation = () => {
 			<!-- (Admin) Pause/Resume Button -->
 			<button
 				class="button is-danger"
-				v-if="isOwnerOrAdmin() && stationPaused"
+				v-if="
+					hasPermission('stations.playback.toggle') && stationPaused
+				"
 				@click="resumeStation()"
 			>
 				<i class="material-icons icon-with-button">play_arrow</i>
@@ -113,7 +119,9 @@ const unfavoriteStation = () => {
 			<button
 				class="button is-danger"
 				@click="pauseStation()"
-				v-if="isOwnerOrAdmin() && !stationPaused"
+				v-if="
+					hasPermission('stations.playback.toggle') && !stationPaused
+				"
 			>
 				<i class="material-icons icon-with-button">pause</i>
 				<span> Pause Station </span>
@@ -123,7 +131,7 @@ const unfavoriteStation = () => {
 			<button
 				class="button is-danger"
 				@click="skipStation()"
-				v-if="isOwnerOrAdmin()"
+				v-if="hasPermission('stations.skip')"
 			>
 				<i class="material-icons icon-with-button">skip_next</i>
 				<span> Force Skip </span>
@@ -141,7 +149,9 @@ const unfavoriteStation = () => {
 						}
 					})
 				"
-				v-if="isOwnerOrAdmin() && showManageStation"
+				v-if="
+					hasPermission('stations.view.manage') && showManageStation
+				"
 			>
 				<i class="material-icons icon-with-button">settings</i>
 				<span> Manage Station </span>

+ 13 - 8
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -11,20 +11,25 @@ const props = defineProps({
 });
 
 const userAuthStore = useUserAuthStore();
-const { userId, role: userRole } = storeToRefs(userAuthStore);
+const { loggedIn, userId } = storeToRefs(userAuthStore);
+const { hasPermission } = userAuthStore;
 
 const { socket } = useWebsocketsStore();
 
 const editPlaylistStore = useEditPlaylistStore(props);
 const { playlist } = storeToRefs(editPlaylistStore);
 
-const isEditable = () =>
-	(playlist.value.type === "user" ||
+const isOwner = () =>
+	loggedIn.value && userId.value === playlist.value.createdBy;
+
+const isEditable = permission =>
+	((playlist.value.type === "user" ||
 		playlist.value.type === "user-liked" ||
 		playlist.value.type === "user-disliked") &&
-	(userId.value === playlist.value.createdBy || userRole.value === "admin");
-
-const isAdmin = () => userRole.value === "admin";
+		(isOwner() || hasPermission(permission))) ||
+	(playlist.value.type === "genre" &&
+		permission === "playlists.update.privacy" &&
+		hasPermission(permission));
 
 const renamePlaylist = () => {
 	const { displayName } = playlist.value;
@@ -66,7 +71,7 @@ const updatePrivacy = () => {
 	<div class="settings-tab section">
 		<div
 			v-if="
-				isEditable() &&
+				isEditable('playlists.update.displayName') &&
 				!(
 					playlist.type === 'user-liked' ||
 					playlist.type === 'user-disliked'
@@ -96,7 +101,7 @@ const updatePrivacy = () => {
 			</div>
 		</div>
 
-		<div v-if="isEditable() || (playlist.type === 'genre' && isAdmin())">
+		<div v-if="isEditable('playlists.update.privacy')">
 			<label class="label"> Change privacy </label>
 			<div class="control is-grouped input-with-button">
 				<div class="control is-expanded select">

+ 53 - 36
frontend/src/components/modals/EditPlaylist/index.vue

@@ -66,16 +66,24 @@ const showTab = payload => {
 	editPlaylistStore.showTab(payload);
 };
 
-const isEditable = () =>
-	(playlist.value.type === "user" ||
+const { hasPermission } = userAuthStore;
+
+const isOwner = () =>
+	loggedIn.value && userId.value === playlist.value.createdBy;
+
+const isEditable = permission =>
+	((playlist.value.type === "user" ||
 		playlist.value.type === "user-liked" ||
 		playlist.value.type === "user-disliked") &&
-	(userId.value === playlist.value.createdBy || userRole.value === "admin");
+		(isOwner() || hasPermission(permission))) ||
+	(playlist.value.type === "genre" &&
+		permission === "playlists.update.privacy" &&
+		hasPermission(permission));
 
 const dragOptions = computed(() => ({
 	animation: 200,
 	group: "songs",
-	disabled: !isEditable(),
+	disabled: !isEditable("playlists.songs.reposition"),
 	ghostClass: "draggable-list-ghost"
 }));
 
@@ -89,11 +97,6 @@ const init = () => {
 	});
 };
 
-const isAdmin = () => userRole.value === "admin";
-
-const isOwner = () =>
-	loggedIn.value && userId.value === playlist.value.createdBy;
-
 const repositionSong = ({ oldIndex, newIndex }) => {
 	if (oldIndex === newIndex) return; // we only need to update when song is moved
 	const song = playlistSongs.value[oldIndex];
@@ -170,7 +173,7 @@ const removePlaylist = () => {
 			new Toast(res.message);
 			if (res.status === "success") closeCurrentModal();
 		});
-	} else if (isAdmin()) {
+	} else if (hasPermission("playlists.removeAdmin")) {
 		socket.dispatch("playlists.removeAdmin", playlist.value._id, res => {
 			new Toast(res.message);
 			if (res.status === "success") closeCurrentModal();
@@ -313,13 +316,15 @@ onBeforeUnmount(() => {
 <template>
 	<modal
 		:title="
-			userId === playlist.createdBy ? 'Edit Playlist' : 'View Playlist'
+			isEditable('playlists.update.privacy')
+				? 'Edit Playlist'
+				: 'View Playlist'
 		"
 		:class="{
 			'edit-playlist-modal': true,
-			'view-only': !isEditable()
+			'view-only': !isEditable('playlists.update.privacy')
 		}"
-		:size="isEditable() ? 'wide' : null"
+		:size="isEditable('playlists.update.privacy') ? 'wide' : null"
 		:split="true"
 	>
 		<template #body>
@@ -337,11 +342,7 @@ onBeforeUnmount(() => {
 							:class="{ selected: tab === 'settings' }"
 							:ref="el => (tabs['settings-tab'] = el)"
 							@click="showTab('settings')"
-							v-if="
-								userId === playlist.createdBy ||
-								isEditable() ||
-								(playlist.type === 'genre' && isAdmin())
-							"
+							v-if="isEditable('playlists.update.privacy')"
 						>
 							Settings
 						</button>
@@ -350,7 +351,7 @@ onBeforeUnmount(() => {
 							:class="{ selected: tab === 'add-songs' }"
 							:ref="el => (tabs['add-songs-tab'] = el)"
 							@click="showTab('add-songs')"
-							v-if="isEditable()"
+							v-if="isEditable('playlists.songs.add')"
 						>
 							Add Songs
 						</button>
@@ -361,7 +362,7 @@ onBeforeUnmount(() => {
 							}"
 							:ref="el => (tabs['import-playlists-tab'] = el)"
 							@click="showTab('import-playlists')"
-							v-if="isEditable()"
+							v-if="isEditable('playlists.songs.add')"
 						>
 							Import Playlists
 						</button>
@@ -369,23 +370,19 @@ onBeforeUnmount(() => {
 					<settings
 						class="tab"
 						v-show="tab === 'settings'"
-						v-if="
-							userId === playlist.createdBy ||
-							isEditable() ||
-							(playlist.type === 'genre' && isAdmin())
-						"
+						v-if="isEditable('playlists.update.privacy')"
 						:modal-uuid="modalUuid"
 					/>
 					<add-songs
 						class="tab"
 						v-show="tab === 'add-songs'"
-						v-if="isEditable()"
+						v-if="isEditable('playlists.songs.add')"
 						:modal-uuid="modalUuid"
 					/>
 					<import-playlists
 						class="tab"
 						v-show="tab === 'import-playlists'"
-						v-if="isEditable()"
+						v-if="isEditable('playlists.songs.add')"
 						:modal-uuid="modalUuid"
 					/>
 				</div>
@@ -393,7 +390,7 @@ onBeforeUnmount(() => {
 
 			<div class="right-section">
 				<div id="rearrange-songs-section" class="section">
-					<div v-if="isEditable()">
+					<div v-if="isEditable('playlists.songs.reposition')">
 						<h4 class="section-title">Rearrange Songs</h4>
 
 						<p class="section-description">
@@ -421,7 +418,9 @@ onBeforeUnmount(() => {
 									<song-item
 										:song="element"
 										:class="{
-											'item-draggable': isEditable()
+											'item-draggable': isEditable(
+												'playlists.songs.reposition'
+											)
 										}"
 										:ref="
 											el =>
@@ -460,7 +459,9 @@ onBeforeUnmount(() => {
 												v-if="
 													userId ===
 														playlist.createdBy ||
-													isEditable()
+													isEditable(
+														'playlists.songs.remove'
+													)
 												"
 												placement="left"
 												@confirm="
@@ -478,7 +479,11 @@ onBeforeUnmount(() => {
 											</quick-confirm>
 											<i
 												class="material-icons"
-												v-if="isEditable() && index > 0"
+												v-if="
+													isEditable(
+														'playlists.songs.reposition'
+													) && index > 0
+												"
 												@click="
 													moveSongToTop(
 														element,
@@ -491,7 +496,9 @@ onBeforeUnmount(() => {
 											>
 											<i
 												v-if="
-													isEditable() &&
+													isEditable(
+														'playlists.songs.reposition'
+													) &&
 													playlistSongs.length - 1 !==
 														index
 												"
@@ -524,14 +531,21 @@ onBeforeUnmount(() => {
 		<template #footer>
 			<button
 				class="button is-default"
-				v-if="isOwner() || isAdmin() || playlist.privacy === 'public'"
+				v-if="
+					isOwner() ||
+					hasPermission('playlists.get') ||
+					playlist.privacy === 'public'
+				"
 				@click="downloadPlaylist()"
 			>
 				Download Playlist
 			</button>
 			<div class="right">
 				<quick-confirm
-					v-if="playlist.type === 'station'"
+					v-if="
+						hasPermission('playlists.clearAndRefill') &&
+						playlist.type === 'station'
+					"
 					@confirm="clearAndRefillStationPlaylist()"
 				>
 					<a class="button is-danger">
@@ -539,7 +553,10 @@ onBeforeUnmount(() => {
 					</a>
 				</quick-confirm>
 				<quick-confirm
-					v-if="playlist.type === 'genre'"
+					v-if="
+						hasPermission('playlists.clearAndRefill') &&
+						playlist.type === 'genre'
+					"
 					@confirm="clearAndRefillGenrePlaylist()"
 				>
 					<a class="button is-danger">
@@ -548,7 +565,7 @@ onBeforeUnmount(() => {
 				</quick-confirm>
 				<quick-confirm
 					v-if="
-						isEditable() &&
+						isEditable('playlists.removeAdmin') &&
 						!(
 							playlist.type === 'user-liked' ||
 							playlist.type === 'user-disliked'

+ 48 - 30
frontend/src/components/modals/ManageStation/index.vue

@@ -37,7 +37,7 @@ const props = defineProps({
 const tabs = ref([]);
 
 const userAuthStore = useUserAuthStore();
-const { loggedIn, userId, role } = storeToRefs(userAuthStore);
+const { loggedIn } = storeToRefs(userAuthStore);
 
 const { socket } = useWebsocketsStore();
 
@@ -64,7 +64,8 @@ const {
 	updateStationPaused,
 	updateCurrentSong,
 	updateStation,
-	updateIsFavorited
+	updateIsFavorited,
+	hasPermission
 } = manageStationStore;
 
 const { closeCurrentModal } = useModalsStore();
@@ -75,20 +76,14 @@ const showTab = payload => {
 	manageStationStore.showTab(payload);
 };
 
-const isOwner = () =>
-	loggedIn.value && station.value && userId.value === station.value.owner;
-
-const isAdmin = () => loggedIn.value && role.value === "admin";
-
-const isOwnerOrAdmin = () => isOwner() || isAdmin();
-
 const canRequest = () =>
 	station.value &&
 	loggedIn.value &&
 	station.value.requests &&
 	station.value.requests.enabled &&
 	(station.value.requests.access === "user" ||
-		(station.value.requests.access === "owner" && isOwnerOrAdmin()));
+		(station.value.requests.access === "owner" &&
+			hasPermission("stations.request")));
 
 const removeStation = () => {
 	socket.dispatch("stations.remove", stationId.value, res => {
@@ -111,8 +106,10 @@ watch(
 	() => station.value.requests,
 	() => {
 		if (tab.value === "request" && !canRequest()) {
-			if (isOwnerOrAdmin()) showTab("settings");
-			else if (!(sector.value === "home" && !isOwnerOrAdmin()))
+			if (hasPermission("stations.update")) showTab("settings");
+			else if (
+				!(sector.value === "home" && !hasPermission("stations.update"))
+			)
 				closeCurrentModal();
 		}
 	}
@@ -130,7 +127,7 @@ onMounted(() => {
 		if (res.status === "success") {
 			editStation(res.data.station);
 
-			if (!isOwnerOrAdmin()) showTab("request");
+			if (!hasPermission("stations.update")) showTab("request");
 
 			const currentSong = res.data.station.currentSong
 				? res.data.station.currentSong
@@ -158,7 +155,7 @@ onMounted(() => {
 				}
 			);
 
-			if (isOwnerOrAdmin()) {
+			if (hasPermission("stations.view")) {
 				socket.dispatch(
 					"playlists.getPlaylistForStation",
 					stationId.value,
@@ -315,7 +312,7 @@ onMounted(() => {
 		{ modalUuid: props.modalUuid }
 	);
 
-	if (isOwnerOrAdmin()) {
+	if (hasPermission("stations.view")) {
 		socket.on(
 			"event:playlist.song.added",
 			res => {
@@ -385,7 +382,7 @@ onBeforeUnmount(() => {
 		() => {}
 	);
 
-	if (isOwnerOrAdmin()) showTab("settings");
+	if (hasPermission("stations.update")) showTab("settings");
 	clearStation();
 
 	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
@@ -397,16 +394,20 @@ onBeforeUnmount(() => {
 	<modal
 		v-if="station"
 		:title="
-			sector === 'home' && !isOwnerOrAdmin()
+			sector === 'home' && !hasPermission('stations.update')
 				? 'View Queue'
-				: !isOwnerOrAdmin()
+				: !hasPermission('stations.update')
 				? 'Add Song to Queue'
 				: 'Manage Station'
 		"
 		:style="`--primary-color: var(--${station.theme})`"
 		class="manage-station-modal"
-		:size="isOwnerOrAdmin() || sector !== 'home' ? 'wide' : null"
-		:split="isOwnerOrAdmin() || sector !== 'home'"
+		:size="
+			hasPermission('stations.update') || sector !== 'home'
+				? 'wide'
+				: null
+		"
+		:split="hasPermission('stations.update') || sector !== 'home'"
 	>
 		<template #body v-if="station && station._id">
 			<div class="left-section">
@@ -416,12 +417,19 @@ onBeforeUnmount(() => {
 							:station="station"
 							:station-paused="stationPaused"
 							:show-go-to-station="sector !== 'station'"
+							:sector="'manageStation'"
+							:modal-uuid="modalUuid"
 						/>
 					</div>
-					<div v-if="isOwnerOrAdmin() || sector !== 'home'">
+					<div
+						v-if="
+							hasPermission('stations.update') ||
+							sector !== 'home'
+						"
+					>
 						<div class="tab-selection">
 							<button
-								v-if="isOwnerOrAdmin()"
+								v-if="hasPermission('stations.update')"
 								class="button is-default"
 								:class="{ selected: tab === 'settings' }"
 								:ref="el => (tabs['settings-tab'] = el)"
@@ -440,7 +448,8 @@ onBeforeUnmount(() => {
 							</button>
 							<button
 								v-if="
-									isOwnerOrAdmin() && station.autofill.enabled
+									hasPermission('stations.view.manage') &&
+									station.autofill.enabled
 								"
 								class="button is-default"
 								:class="{ selected: tab === 'autofill' }"
@@ -450,7 +459,7 @@ onBeforeUnmount(() => {
 								Autofill
 							</button>
 							<button
-								v-if="isOwnerOrAdmin()"
+								v-if="hasPermission('stations.view.manage')"
 								class="button is-default"
 								:class="{ selected: tab === 'blacklist' }"
 								:ref="el => (tabs['blacklist-tab'] = el)"
@@ -460,7 +469,7 @@ onBeforeUnmount(() => {
 							</button>
 						</div>
 						<settings
-							v-if="isOwnerOrAdmin()"
+							v-if="hasPermission('stations.update')"
 							class="tab"
 							v-show="tab === 'settings'"
 							:modal-uuid="modalUuid"
@@ -475,7 +484,10 @@ onBeforeUnmount(() => {
 							:modal-uuid="modalUuid"
 						/>
 						<playlist-tab-base
-							v-if="isOwnerOrAdmin() && station.autofill.enabled"
+							v-if="
+								hasPermission('stations.view.manage') &&
+								station.autofill.enabled
+							"
 							class="tab"
 							v-show="tab === 'autofill'"
 							:type="'autofill'"
@@ -489,7 +501,7 @@ onBeforeUnmount(() => {
 							</template>
 						</playlist-tab-base>
 						<playlist-tab-base
-							v-if="isOwnerOrAdmin()"
+							v-if="hasPermission('stations.view.manage')"
 							class="tab"
 							v-show="tab === 'blacklist'"
 							:type="'blacklist'"
@@ -523,11 +535,17 @@ onBeforeUnmount(() => {
 			</div>
 		</template>
 		<template #footer>
-			<div v-if="isOwnerOrAdmin()" class="right">
-				<quick-confirm @confirm="resetQueue()">
+			<div class="right">
+				<quick-confirm
+					v-if="hasPermission('stations.queue.remove')"
+					@confirm="resetQueue()"
+				>
 					<a class="button is-danger">Reset queue</a>
 				</quick-confirm>
-				<quick-confirm @confirm="removeStation()">
+				<quick-confirm
+					v-if="hasPermission('stations.queue.reset')"
+					@confirm="removeStation()"
+				>
 					<button class="button is-danger">Delete station</button>
 				</quick-confirm>
 			</div>

+ 44 - 17
frontend/src/main.ts

@@ -164,57 +164,75 @@ const router = createRouter({
 			children: [
 				{
 					path: "songs",
-					component: () => import("@/pages/Admin/Songs/index.vue")
+					component: () => import("@/pages/Admin/Songs/index.vue"),
+					meta: { permissionRequired: "admin.view.songs" }
 				},
 				{
 					path: "songs/import",
-					component: () => import("@/pages/Admin/Songs/Import.vue")
+					component: () => import("@/pages/Admin/Songs/Import.vue"),
+					meta: { permissionRequired: "admin.view.import" }
 				},
 				{
 					path: "reports",
-					component: () => import("@/pages/Admin/Reports.vue")
+					component: () => import("@/pages/Admin/Reports.vue"),
+					meta: { permissionRequired: "admin.view.reports" }
 				},
 				{
 					path: "stations",
-					component: () => import("@/pages/Admin/Stations.vue")
+					component: () => import("@/pages/Admin/Stations.vue"),
+					meta: { permissionRequired: "admin.view.stations" }
 				},
 				{
 					path: "playlists",
-					component: () => import("@/pages/Admin/Playlists.vue")
+					component: () => import("@/pages/Admin/Playlists.vue"),
+					meta: { permissionRequired: "admin.view.playlists" }
 				},
 				{
 					path: "users",
-					component: () => import("@/pages/Admin/Users/index.vue")
+					component: () => import("@/pages/Admin/Users/index.vue"),
+					meta: { permissionRequired: "admin.view.users" }
 				},
 				{
 					path: "users/data-requests",
 					component: () =>
-						import("@/pages/Admin/Users/DataRequests.vue")
+						import("@/pages/Admin/Users/DataRequests.vue"),
+					meta: { permissionRequired: "admin.view.users" }
 				},
 				{
 					path: "users/punishments",
 					component: () =>
-						import("@/pages/Admin/Users/Punishments.vue")
+						import("@/pages/Admin/Users/Punishments.vue"),
+					meta: {
+						permissionRequired: "admin.view.punishments"
+					}
 				},
 				{
 					path: "news",
-					component: () => import("@/pages/Admin/News.vue")
+					component: () => import("@/pages/Admin/News.vue"),
+					meta: { permissionRequired: "admin.view.news" }
 				},
 				{
 					path: "statistics",
-					component: () => import("@/pages/Admin/Statistics.vue")
+					component: () => import("@/pages/Admin/Statistics.vue"),
+					meta: {
+						permissionRequired: "admin.view.statistics"
+					}
 				},
 				{
 					path: "youtube",
-					component: () => import("@/pages/Admin/YouTube/index.vue")
+					component: () => import("@/pages/Admin/YouTube/index.vue"),
+					meta: { permissionRequired: "admin.view.youtube" }
 				},
 				{
 					path: "youtube/videos",
-					component: () => import("@/pages/Admin/YouTube/Videos.vue")
+					component: () => import("@/pages/Admin/YouTube/Videos.vue"),
+					meta: {
+						permissionRequired: "admin.view.youtubeVideos"
+					}
 				}
 			],
 			meta: {
-				adminRequired: true
+				permissionRequired: "admin.view"
 			}
 		},
 		{
@@ -245,11 +263,18 @@ router.beforeEach((to, from, next) => {
 		ws.destroyListeners();
 	}
 
-	if (to.meta.loginRequired || to.meta.adminRequired || to.meta.guestsOnly) {
+	if (
+		to.meta.loginRequired ||
+		to.meta.permissionRequired ||
+		to.meta.guestsOnly
+	) {
 		const gotData = () => {
 			if (to.meta.loginRequired && !userAuthStore.loggedIn)
 				next({ path: "/login" });
-			else if (to.meta.adminRequired && userAuthStore.role !== "admin")
+			else if (
+				to.meta.permissionRequired &&
+				!userAuthStore.hasPermission(to.meta.permissionRequired)
+			)
 				next({ path: "/" });
 			else if (to.meta.guestsOnly && userAuthStore.loggedIn)
 				next({ path: "/" });
@@ -301,14 +326,16 @@ lofig.folder = defaultConfigURL;
 	if (await lofig.get("siteSettings.mediasession")) ms.init();
 
 	ws.socket.on("ready", res => {
-		const { loggedIn, role, username, userId, email } = res.data;
+		const { loggedIn, role, username, userId, email, permissions } =
+			res.data;
 
 		userAuthStore.authData({
 			loggedIn,
 			role,
 			username,
 			email,
-			userId
+			userId,
+			permissions
 		});
 	});
 

+ 61 - 8
frontend/src/pages/Admin/index.vue

@@ -8,6 +8,7 @@ import {
 } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { useWebsocketsStore } from "@/stores/websockets";
+import { useUserAuthStore } from "@/stores/userAuth";
 import keyboardShortcuts from "@/keyboardShortcuts";
 
 const MainHeader = defineAsyncComponent(
@@ -25,6 +26,8 @@ const router = useRouter();
 
 const { socket } = useWebsocketsStore();
 
+const { hasPermission } = useUserAuthStore();
+
 const currentTab = ref("");
 const siteSettings = ref({
 	logo: "",
@@ -182,7 +185,10 @@ onBeforeUnmount(() => {
 								<span>Minimise</span>
 							</div>
 							<div
-								v-if="sidebarActive"
+								v-if="
+									hasPermission('admin.view.songs') &&
+									sidebarActive
+								"
 								class="sidebar-item with-children"
 								:class="{ 'is-active': childrenActive.songs }"
 							>
@@ -212,6 +218,9 @@ onBeforeUnmount(() => {
 										Songs
 									</router-link>
 									<router-link
+										v-if="
+											hasPermission('admin.view.import')
+										"
 										class="sidebar-item-child"
 										to="/admin/songs/import"
 									>
@@ -220,7 +229,10 @@ onBeforeUnmount(() => {
 								</div>
 							</div>
 							<router-link
-								v-else
+								v-else-if="
+									hasPermission('admin.view.users') &&
+									!sidebarActive
+								"
 								class="sidebar-item songs"
 								to="/admin/songs"
 								content="Songs"
@@ -233,6 +245,7 @@ onBeforeUnmount(() => {
 								<span>Songs</span>
 							</router-link>
 							<router-link
+								v-if="hasPermission('admin.view.reports')"
 								class="sidebar-item reports"
 								to="/admin/reports"
 								content="Reports"
@@ -245,6 +258,7 @@ onBeforeUnmount(() => {
 								<span>Reports</span>
 							</router-link>
 							<router-link
+								v-if="hasPermission('admin.view.stations')"
 								class="sidebar-item stations"
 								to="/admin/stations"
 								content="Stations"
@@ -257,6 +271,7 @@ onBeforeUnmount(() => {
 								<span>Stations</span>
 							</router-link>
 							<router-link
+								v-if="hasPermission('admin.view.playlists')"
 								class="sidebar-item playlists"
 								to="/admin/playlists"
 								content="Playlists"
@@ -269,7 +284,10 @@ onBeforeUnmount(() => {
 								<span>Playlists</span>
 							</router-link>
 							<div
-								v-if="sidebarActive"
+								v-if="
+									hasPermission('admin.view.users') &&
+									sidebarActive
+								"
 								class="sidebar-item with-children"
 								:class="{ 'is-active': childrenActive.users }"
 							>
@@ -313,7 +331,10 @@ onBeforeUnmount(() => {
 								</div>
 							</div>
 							<router-link
-								v-else
+								v-else-if="
+									hasPermission('admin.view.users') &&
+									!sidebarActive
+								"
 								class="sidebar-item users"
 								to="/admin/users"
 								content="Users"
@@ -326,6 +347,7 @@ onBeforeUnmount(() => {
 								<span>Users</span>
 							</router-link>
 							<router-link
+								v-if="hasPermission('admin.view.news')"
 								class="sidebar-item news"
 								to="/admin/news"
 								content="News"
@@ -338,6 +360,7 @@ onBeforeUnmount(() => {
 								<span>News</span>
 							</router-link>
 							<router-link
+								v-if="hasPermission('admin.view.statistics')"
 								class="sidebar-item statistics"
 								to="/admin/statistics"
 								content="Statistics"
@@ -350,12 +373,24 @@ onBeforeUnmount(() => {
 								<span>Statistics</span>
 							</router-link>
 							<div
-								v-if="sidebarActive"
+								v-if="
+									(hasPermission('admin.view.youtube') ||
+										hasPermission(
+											'admin.view.youtubeVideos'
+										)) &&
+									sidebarActive
+								"
 								class="sidebar-item with-children"
 								:class="{ 'is-active': childrenActive.youtube }"
 							>
 								<span>
-									<router-link to="/admin/youtube">
+									<router-link
+										:to="`/admin/youtube${
+											hasPermission('admin.view.youtube')
+												? ''
+												: '/videos'
+										}`"
+									>
 										<i class="material-icons"
 											>smart_display</i
 										>
@@ -376,12 +411,20 @@ onBeforeUnmount(() => {
 								</span>
 								<div class="sidebar-item-children">
 									<router-link
+										v-if="
+											hasPermission('admin.view.youtube')
+										"
 										class="sidebar-item-child"
 										to="/admin/youtube"
 									>
 										YouTube
 									</router-link>
 									<router-link
+										v-if="
+											hasPermission(
+												'admin.view.youtubeVideos'
+											)
+										"
 										class="sidebar-item-child"
 										to="/admin/youtube/videos"
 									>
@@ -390,9 +433,19 @@ onBeforeUnmount(() => {
 								</div>
 							</div>
 							<router-link
-								v-else
+								v-else-if="
+									(hasPermission('admin.view.youtube') ||
+										hasPermission(
+											'admin.view.youtubeVideos'
+										)) &&
+									!sidebarActive
+								"
 								class="sidebar-item youtube"
-								to="/admin/youtube"
+								:to="`/admin/youtube${
+									hasPermission('admin.view.youtube')
+										? ''
+										: '/videos'
+								}`"
 								content="YouTube"
 								v-tippy="{
 									theme: 'info',

+ 17 - 9
frontend/src/pages/Home.vue

@@ -33,7 +33,8 @@ const userAuthStore = useUserAuthStore();
 const route = useRoute();
 const router = useRouter();
 
-const { loggedIn, userId, role } = storeToRefs(userAuthStore);
+const { loggedIn, userId } = storeToRefs(userAuthStore);
+const { hasPermission } = userAuthStore;
 
 const { socket } = useWebsocketsStore();
 
@@ -50,10 +51,6 @@ const changeFavoriteOrderDebounceTimeout = ref();
 
 const isOwner = station => loggedIn.value && station.owner === userId.value;
 
-const isAdmin = () => loggedIn.value && role.value === "admin";
-
-const isOwnerOrAdmin = station => isOwner(station) || isAdmin();
-
 const isPlaying = station => typeof station.currentSong.title !== "undefined";
 
 const filteredStations = computed(() => {
@@ -136,7 +133,8 @@ const canRequest = (station, requireLogin = true) =>
 	station.requests &&
 	station.requests.enabled &&
 	(station.requests.access === "user" ||
-		(station.requests.access === "owner" && isOwnerOrAdmin(station)));
+		(station.requests.access === "owner" &&
+			(isOwner(station) || hasPermission("stations.request"))));
 
 const favoriteStation = stationId => {
 	socket.dispatch("stations.favoriteStation", stationId, res => {
@@ -332,7 +330,7 @@ onMounted(async () => {
 		ctrl: true,
 		alt: true,
 		handler: () => {
-			if (isAdmin())
+			if (hasPermission("stations.index.other"))
 				if (route.query.adminFilter === undefined)
 					router.push({
 						query: {
@@ -437,7 +435,12 @@ onBeforeUnmount(() => {
 									<template #icon>
 										<div class="icon-container">
 											<div
-												v-if="isOwnerOrAdmin(element)"
+												v-if="
+													isOwner(element) ||
+													hasPermission(
+														'stations.view.manage'
+													)
+												"
 												class="material-icons manage-station"
 												@click.prevent="
 													openModal({
@@ -701,7 +704,12 @@ onBeforeUnmount(() => {
 							<template #icon>
 								<div class="icon-container">
 									<div
-										v-if="isOwnerOrAdmin(station)"
+										v-if="
+											isOwner(station) ||
+											hasPermission(
+												'stations.view.manage'
+											)
+										"
 										class="material-icons manage-station"
 										@click.prevent="
 											openModal({

+ 6 - 3
frontend/src/pages/Profile/index.vue

@@ -33,7 +33,8 @@ const userId = ref("");
 const isUser = ref(false);
 
 const userAuthStore = useUserAuthStore();
-const { userId: myUserId, role } = storeToRefs(userAuthStore);
+const { userId: myUserId } = storeToRefs(userAuthStore);
+const { hasPermission } = userAuthStore;
 
 const init = () => {
 	socket.dispatch("users.getBasicUser", route.params.username, res => {
@@ -95,12 +96,14 @@ onMounted(() => {
 				</div>
 				<div
 					class="buttons"
-					v-if="myUserId === userId || role === 'admin'"
+					v-if="
+						myUserId === userId || hasPermission('admin.view.users')
+					"
 				>
 					<router-link
 						:to="`/admin/users?userId=${user._id}`"
 						class="button is-primary"
-						v-if="role === 'admin'"
+						v-if="hasPermission('admin.view.users')"
 					>
 						Edit
 					</router-link>

+ 4 - 7
frontend/src/pages/Station/Sidebar/index.vue

@@ -18,13 +18,9 @@ const stationStore = useStationStore();
 
 const { tab, showTab } = useTabQueryHandler("queue");
 
-const { loggedIn, userId, role } = storeToRefs(userAuthStore);
+const { loggedIn } = storeToRefs(userAuthStore);
 const { station } = storeToRefs(stationStore);
-
-const isOwner = () =>
-	loggedIn.value && station.value && userId.value === station.value.owner;
-const isAdmin = () => loggedIn.value && role.value === "admin";
-const isOwnerOrAdmin = () => isOwner() || isAdmin();
+const { hasPermission } = stationStore;
 
 const canRequest = (requireLogin = true) =>
 	station.value &&
@@ -32,7 +28,8 @@ const canRequest = (requireLogin = true) =>
 	station.value.requests &&
 	station.value.requests.enabled &&
 	(station.value.requests.access === "user" ||
-		(station.value.requests.access === "owner" && isOwnerOrAdmin()));
+		(station.value.requests.access === "owner" &&
+			hasPermission("stations.request")));
 
 watch(
 	() => station.value.requests,

+ 25 - 16
frontend/src/pages/Station/index.vue

@@ -107,7 +107,7 @@ const { activeModals } = storeToRefs(modalsStore);
 // TODO fix this if it still has some use, as this is no longer accurate
 // const video = computed(() => store.state.modals.editSong);
 
-const { loggedIn, userId, role } = storeToRefs(userAuthStore);
+const { loggedIn, userId } = storeToRefs(userAuthStore);
 const { nightmode, autoSkipDisliked } = storeToRefs(userPreferencesStore);
 const {
 	station,
@@ -168,17 +168,14 @@ const {
 	updateCurrentSongRatings,
 	updateOwnCurrentSongRatings,
 	updateCurrentSongSkipVotes,
-	updateAutoRequestLock
+	updateAutoRequestLock,
+	hasPermission
 } = stationStore;
 
 // TODO fix this if it still has some use
 // const stopVideo = payload =>
 // 	store.dispatch("modals/editSong/stopVideo", payload);
 
-const isOwnerOnly = () =>
-	loggedIn.value && userId.value === station.value.owner;
-const isAdminOnly = () => loggedIn.value && role.value === "admin";
-const isOwnerOrAdmin = () => isOwnerOnly() || isAdminOnly();
 const updateMediaSessionData = song => {
 	if (song) {
 		ms.setMediaSessionData(
@@ -819,7 +816,8 @@ const join = () => {
 				type,
 				isFavorited,
 				theme,
-				requests
+				requests,
+				permissions
 			} = res.data;
 
 			// change url to use station name instead of station id
@@ -840,7 +838,8 @@ const join = () => {
 				type,
 				isFavorited,
 				theme,
-				requests
+				requests,
+				permissions
 			});
 
 			document.getElementsByTagName(
@@ -888,7 +887,7 @@ const join = () => {
 				}
 			});
 
-			if (isOwnerOrAdmin()) {
+			if (hasPermission("stations.playback.toggle"))
 				keyboardShortcuts.registerShortcut("station.pauseResume", {
 					keyCode: 32, // Spacebar
 					shift: false,
@@ -901,6 +900,7 @@ const join = () => {
 					}
 				});
 
+			if (hasPermission("stations.skip"))
 				keyboardShortcuts.registerShortcut("station.skipStation", {
 					keyCode: 39, // Right arrow key
 					shift: false,
@@ -911,7 +911,6 @@ const join = () => {
 						skipStation();
 					}
 				});
-			}
 
 			keyboardShortcuts.registerShortcut("station.lowerVolumeLarge", {
 				keyCode: 40, // Down arrow key
@@ -1171,15 +1170,15 @@ onMounted(async () => {
 
 	ms.setListeners(0, {
 		play: () => {
-			if (isOwnerOrAdmin()) resumeStation();
+			if (hasPermission("stations.playback.toggle")) resumeStation();
 			else resumeLocalStation();
 		},
 		pause: () => {
-			if (isOwnerOrAdmin()) pauseStation();
+			if (hasPermission("stations.playback.toggle")) pauseStation();
 			else pauseLocalStation();
 		},
 		nexttrack: () => {
-			if (isOwnerOrAdmin()) skipStation();
+			if (hasPermission("stations.skip")) skipStation();
 			else voteSkipStation();
 		}
 	});
@@ -1290,7 +1289,7 @@ onMounted(async () => {
 	socket.on("event:station.updated", async res => {
 		const { name, theme, privacy } = res.data.station;
 
-		if (!isOwnerOrAdmin() && privacy === "private") {
+		if (!hasPermission("stations.view") && privacy === "private") {
 			window.location.href =
 				"/?msg=The station you were in was made private.";
 		} else {
@@ -2046,12 +2045,22 @@ onBeforeUnmount(() => {
 		>
 			<template #body>
 				<div>
-					<div v-if="isOwnerOrAdmin()">
+					<div
+						v-if="
+							hasPermission('stations.playback.toggle') ||
+							hasPermission('stations.skip')
+						"
+					>
 						<span class="biggest"><b>Admin/owner</b></span>
 						<span><b>Ctrl + Space</b> - Pause/resume station</span>
 						<span><b>Ctrl + Numpad right</b> - Skip station</span>
 					</div>
-					<hr v-if="isOwnerOrAdmin()" />
+					<hr
+						v-if="
+							hasPermission('stations.playback.toggle') ||
+							hasPermission('stations.skip')
+						"
+					/>
 					<div>
 						<span class="biggest"><b>Volume</b></span>
 						<span

+ 6 - 0
frontend/src/stores/manageStation.ts

@@ -75,6 +75,12 @@ export const useManageStationStore = props => {
 			},
 			updateIsFavorited(isFavorited) {
 				this.station.isFavorited = isFavorited;
+			},
+			hasPermission(permission) {
+				return !!(
+					this.station.permissions &&
+					this.station.permissions[permission]
+				);
 			}
 		}
 	})();

+ 5 - 0
frontend/src/stores/station.ts

@@ -131,6 +131,11 @@ export const useStationStore = defineStore("station", {
 		},
 		updateMediaModalPlayingAudio(mediaModalPlayingAudio) {
 			this.mediaModalPlayingAudio = mediaModalPlayingAudio;
+		},
+		hasPermission(permission) {
+			return !!(
+				this.station.permissions && this.station.permissions[permission]
+			);
 		}
 	}
 });

+ 6 - 1
frontend/src/stores/userAuth.ts

@@ -18,7 +18,8 @@ export const useUserAuthStore = defineStore("userAuth", {
 			reason: null,
 			expiresAt: null
 		},
-		gotData: false
+		gotData: false,
+		permissions: {}
 	}),
 	actions: {
 		register(user) {
@@ -231,6 +232,7 @@ export const useUserAuthStore = defineStore("userAuth", {
 			this.username = data.username;
 			this.email = data.email;
 			this.userId = data.userId;
+			this.permissions = data.permissions || {};
 			this.gotData = true;
 		},
 		banUser(ban) {
@@ -239,6 +241,9 @@ export const useUserAuthStore = defineStore("userAuth", {
 		},
 		updateUsername(username) {
 			this.username = username;
+		},
+		hasPermission(permission) {
+			return !!(this.permissions && this.permissions[permission]);
 		}
 	}
 });