Browse Source

feat(Station): Started adding DJ role functionality

Owen Diffey 1 year ago
parent
commit
85c856aba1

+ 96 - 17
backend/logic/actions/stations.js

@@ -771,7 +771,8 @@ export default {
 	 * @param {string} stationIdentifier - the station name or station id
 	 * @param {Function} cb - callback
 	 */
-	join(session, stationIdentifier, cb) {
+	async join(session, stationIdentifier, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
 		async.waterfall(
 			[
 				next => {
@@ -817,7 +818,8 @@ export default {
 						autofill: station.autofill,
 						owner: station.owner,
 						blacklist: station.blacklist,
-						theme: station.theme
+						theme: station.theme,
+						djs: station.djs
 					};
 
 					StationsModule.userList[session.socketId] = station._id;
@@ -825,6 +827,19 @@ export default {
 					next(null, data);
 				},
 
+				(data, next) => {
+					userModel.find({ _id: { $in: data.djs } }, (err, users) => {
+						if (err) next(err);
+						else {
+							data.djs = users.map(user => {
+								const { _id, name, username, avatar } = user._doc;
+								return { _id, name, username, avatar };
+							});
+							next(null, data);
+						}
+					});
+				},
+
 				(data, next) => {
 					data = JSON.parse(JSON.stringify(data));
 
@@ -862,18 +877,6 @@ 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) => {
@@ -2563,9 +2566,15 @@ export default {
 	 * @param {string} stationId - the station id
 	 * @param {Function} cb - gets called with the result
 	 */
-	resetQueue: useHasPermission("stations.queue.reset", async function resetQueue(session, stationId, cb) {
+	async resetQueue(session, stationId, cb) {
 		async.waterfall(
 			[
+				next => {
+					hasPermission("stations.queue.reset", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
 				next => {
 					StationsModule.runJob("RESET_QUEUE", { stationId }, this)
 						.then(() => next())
@@ -2582,7 +2591,7 @@ export default {
 				return cb({ status: "success", message: "Successfully reset station queue." });
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Gets skip votes for a station
@@ -2637,5 +2646,75 @@ export default {
 				});
 			}
 		);
-	})
+	}),
+
+	/**
+	 * Add DJ to station
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} stationId - the station id
+	 * @param {string} userId - the dj user id
+	 * @param {Function} cb - gets called with the result
+	 */
+	async addDj(session, stationId, userId, cb) {
+		async.waterfall(
+			[
+				next => {
+					hasPermission("stations.djs.add", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
+				next => {
+					StationsModule.runJob("ADD_DJ", { stationId, userId }, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "ADD_DJ", `Adding DJ failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "ADD_DJ", "Adding DJ was successful.");
+				return cb({ status: "success", message: "Successfully added DJ." });
+			}
+		);
+	},
+
+	/**
+	 * Remove DJ from station
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} stationId - the station id
+	 * @param {string} userId - the dj user id
+	 * @param {Function} cb - gets called with the result
+	 */
+	async removeDj(session, stationId, userId, cb) {
+		async.waterfall(
+			[
+				next => {
+					hasPermission("stations.djs.remove", session, stationId)
+						.then(() => next())
+						.catch(next);
+				},
+
+				next => {
+					StationsModule.runJob("REMOVE_DJ", { stationId, userId }, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "REMOVE_DJ", `Removing DJ failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "REMOVE_DJ", "Removing DJ was successful.");
+				return cb({ status: "success", message: "Successfully removed DJ." });
+			}
+		);
+	}
 };

+ 21 - 4
backend/logic/actions/users.js

@@ -170,6 +170,17 @@ CacheModule.runJob("SUB", {
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "user.updateRole",
+	cb: ({ user }) => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: user._id }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("keep.event:user.role.updated", { data: { role: user.role } });
+			});
+		});
+	}
+});
+
 CacheModule.runJob("SUB", {
 	channel: "user.updated",
 	cb: async data => {
@@ -2462,18 +2473,19 @@ export default {
 					(user, next) => {
 						if (!user) return next("User not found.");
 						if (user.role === newRole) return next("New role can't be the same as the old role.");
-						return next();
+						return next(null, user);
 					},
-					next => {
+
+					(user, next) => {
 						userModel.updateOne(
 							{ _id: updatingUserId },
 							{ $set: { role: newRole } },
 							{ runValidators: true },
-							next
+							err => next(err, user)
 						);
 					}
 				],
-				async err => {
+				async (err, user) => {
 					if (err && err !== true) {
 						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
@@ -2497,6 +2509,11 @@ export default {
 						value: { userId: updatingUserId }
 					});
 
+					CacheModule.runJob("PUB", {
+						channel: "user.updateRole",
+						value: { user }
+					});
+
 					return cb({
 						status: "success",
 						message: "Role successfully updated."

+ 34 - 1
backend/logic/actions/utils.js

@@ -1,6 +1,6 @@
 import async from "async";
 
-import { useHasPermission } from "../hooks/hasPermission";
+import { useHasPermission, getUserPermissions } from "../hooks/hasPermission";
 
 // eslint-disable-next-line
 import moduleManager from "../../index";
@@ -94,5 +94,38 @@ export default {
 				this.log("ERROR", "GET_ROOMS", `Failed to get rooms. '${err}'`);
 				cb({ status: "error", message: err });
 			});
+	},
+
+	/**
+	 * Get permissions
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} stationId - optional, the station id
+	 * @param {Function} cb - gets called with the result
+	 */
+	async getPermissions(session, stationId, cb) {
+		const callback = cb || stationId;
+		async.waterfall(
+			[
+				next => {
+					getUserPermissions(session.userId, cb ? stationId : null)
+						.then(permissions => {
+							next(null, permissions);
+						})
+						.catch(() => {
+							next(null, {});
+						});
+				}
+			],
+			async (err, permissions) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_PERMISSIONS", `Fetching permissions failed. "${err}"`);
+					return callback({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "GET_PERMISSIONS", "Fetching permissions was successful.");
+				return callback({ status: "success", data: { permissions } });
+			}
+		);
 	}
 };

+ 1 - 0
backend/logic/db/schemas/station.js

@@ -53,5 +53,6 @@ export default {
 	},
 	theme: { type: String, enum: ["blue", "purple", "teal", "orange", "red"], default: "blue" },
 	blacklist: [{ type: mongoose.Schema.Types.ObjectId, ref: "playlists" }],
+	djs: [{ type: mongoose.Schema.Types.ObjectId, ref: "users" }],
 	documentVersion: { type: Number, default: 8, required: true }
 };

+ 7 - 6
backend/logic/hooks/hasPermission.js

@@ -19,6 +19,8 @@ permissions.dj = {
 };
 permissions.owner = {
 	...permissions.dj,
+	"stations.djs.add": true,
+	"stations.djs.remove": true,
 	"stations.remove": true,
 	"stations.update": true
 };
@@ -133,8 +135,8 @@ export const hasPermission = async (permission, session, stationId) => {
 							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 (station.type === "community" && station.djs.find(dj => dj === user._id.toString()))
+								return next(null, [user.role, "dj"]);
 							if (user.role === "admin" || user.role === "moderator") return next(null, [user.role]);
 							return next("Invalid permissions.");
 						})
@@ -177,14 +179,13 @@ 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)
+					return hasPermission(permission, session)
 						.then(() => next())
 						.catch(next);
 				}
@@ -249,8 +250,8 @@ export const getUserPermissions = async (session, stationId) => {
 							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 (station.type === "community" && station.djs.find(dj => dj === user._id.toString()))
+								return next(null, [user.role, "dj"]);
 							if (user.role === "admin" || user.role === "moderator") return next(null, [user.role]);
 							return next("Invalid permissions.");
 						})

+ 158 - 0
backend/logic/stations.js

@@ -79,6 +79,48 @@ class _StationsModule extends CoreClass {
 			}
 		});
 
+		const userModel = (this.userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }));
+
+		CacheModule.runJob("SUB", {
+			channel: "station.djs.added",
+			cb: async ({ stationId, userId }) => {
+				userModel.findOne({ _id: userId }, (err, user) => {
+					if (!err && user) {
+						const { _id, name, username, avatar } = user;
+						const data = { user: { _id, name, username, avatar } };
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: `station.${stationId}`,
+							args: ["event:station.djs.added", { data }]
+						});
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: `manage-station.${stationId}`,
+							args: ["event:manageStation.djs.added", { data: { ...data, stationId } }]
+						});
+					}
+				});
+			}
+		});
+
+		CacheModule.runJob("SUB", {
+			channel: "station.djs.removed",
+			cb: async ({ stationId, userId }) => {
+				userModel.findOne({ _id: userId }, (err, user) => {
+					if (!err && user) {
+						const { _id, name, username, avatar } = user;
+						const data = { user: { _id, name, username, avatar } };
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: `station.${stationId}`,
+							args: ["event:station.djs.removed", { data }]
+						});
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: `manage-station.${stationId}`,
+							args: ["event:manageStation.djs.removed", { data: { ...data, stationId } }]
+						});
+					}
+				});
+			}
+		});
+
 		const stationModel = (this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }));
 		const stationSchema = (this.stationSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "station" }));
 
@@ -1921,6 +1963,122 @@ class _StationsModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Add DJ to station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @param {string} payload.userId - the dj user id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	ADD_DJ(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId, userId } = payload;
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (!station) return next("Station not found.");
+						if (station.djs.find(dj => dj === userId)) return next("That user is already a DJ.");
+
+						return StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $push: { djs: userId } },
+							next
+						);
+					},
+
+					(res, next) => {
+						StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+							.then(() => next())
+							.catch(next);
+					},
+
+					next =>
+						CacheModule.runJob(
+							"PUB",
+							{
+								channel: "station.djs.added",
+								value: { stationId, userId }
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next)
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Remove DJ from station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @param {string} payload.userId - the dj user id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_DJ(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId, userId } = payload;
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (!station) return next("Station not found.");
+						if (!station.djs.find(dj => dj === userId)) return next("That user is not currently a DJ.");
+
+						return StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $pull: { djs: userId } },
+							next
+						);
+					},
+
+					(res, next) => {
+						StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+							.then(() => next())
+							.catch(next);
+					},
+
+					next =>
+						CacheModule.runJob(
+							"PUB",
+							{
+								channel: "station.djs.removed",
+								value: { stationId, userId }
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next)
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
 }
 
 export default new _StationsModule();

+ 1 - 0
backend/logic/tasks.js

@@ -403,6 +403,7 @@ class _TasksModule extends CoreClass {
 									usersPerStationCount[stationId] += 1; // increment user count for station
 
 									return next(null, {
+										_id: user._id,
 										username: user.username,
 										name: user.name,
 										avatar: user.avatar

+ 3 - 13
backend/logic/ws.js

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

+ 27 - 4
frontend/src/components/modals/ManageStation/index.vue

@@ -37,7 +37,7 @@ const props = defineProps({
 const tabs = ref([]);
 
 const userAuthStore = useUserAuthStore();
-const { loggedIn } = storeToRefs(userAuthStore);
+const { loggedIn, userId } = storeToRefs(userAuthStore);
 
 const { socket } = useWebsocketsStore();
 
@@ -65,7 +65,10 @@ const {
 	updateCurrentSong,
 	updateStation,
 	updateIsFavorited,
-	hasPermission
+	hasPermission,
+	addDj,
+	removeDj,
+	updatePermissions
 } = manageStationStore;
 
 const { closeCurrentModal } = useModalsStore();
@@ -123,10 +126,12 @@ watch(
 );
 
 onMounted(() => {
-	socket.dispatch(`stations.getStationById`, stationId.value, res => {
+	socket.dispatch(`stations.getStationById`, stationId.value, async res => {
 		if (res.status === "success") {
 			editStation(res.data.station);
 
+			await updatePermissions();
+
 			if (!hasPermission("stations.update")) showTab("request");
 
 			const currentSong = res.data.station.currentSong
@@ -312,6 +317,24 @@ onMounted(() => {
 		{ modalUuid: props.modalUuid }
 	);
 
+	socket.on("event:manageStation.djs.added", res => {
+		if (res.data.stationId === stationId.value) {
+			if (res.data.user._id === userId.value) updatePermissions();
+			addDj(res.data.user);
+		}
+	});
+
+	socket.on("event:manageStation.djs.removed", res => {
+		if (res.data.stationId === stationId.value) {
+			if (res.data.user._id === userId.value) updatePermissions();
+			removeDj(res.data.user);
+		}
+	});
+
+	socket.on("keep.event:user.role.updated", () => {
+		updatePermissions();
+	});
+
 	if (hasPermission("stations.view")) {
 		socket.on(
 			"event:playlist.song.added",
@@ -537,7 +560,7 @@ onBeforeUnmount(() => {
 		<template #footer>
 			<div class="right">
 				<quick-confirm
-					v-if="hasPermission('stations.queue.remove')"
+					v-if="hasPermission('stations.queue.reset')"
 					@confirm="resetQueue()"
 				>
 					<a class="button is-danger">Reset queue</a>

+ 6 - 4
frontend/src/main.ts

@@ -326,16 +326,14 @@ lofig.folder = defaultConfigURL;
 	if (await lofig.get("siteSettings.mediasession")) ms.init();
 
 	ws.socket.on("ready", res => {
-		const { loggedIn, role, username, userId, email, permissions } =
-			res.data;
+		const { loggedIn, role, username, userId, email } = res.data;
 
 		userAuthStore.authData({
 			loggedIn,
 			role,
 			username,
 			email,
-			userId,
-			permissions
+			userId
 		});
 	});
 
@@ -376,5 +374,9 @@ lofig.folder = defaultConfigURL;
 			changeActivityWatch(preferences.activityWatch);
 	});
 
+	ws.socket.on("keep.event:user.role.updated", res => {
+		userAuthStore.updateRole(res.data.role);
+	});
+
 	app.mount("#root");
 })();

+ 279 - 89
frontend/src/pages/Station/Sidebar/Users.vue

@@ -3,7 +3,9 @@ import { useRoute } from "vue-router";
 import { defineAsyncComponent, ref, onMounted } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
 import { useStationStore } from "@/stores/station";
+import { useUserAuthStore } from "@/stores/userAuth";
 
 const ProfilePicture = defineAsyncComponent(
 	() => import("@/components/ProfilePicture.vue")
@@ -14,8 +16,14 @@ const route = useRoute();
 
 const notesUri = ref("");
 const frontendDomain = ref("");
+const tab = ref("active");
+const tabs = ref([]);
 
-const { users, userCount } = storeToRefs(stationStore);
+const { socket } = useWebsocketsStore();
+
+const { station, users, userCount } = storeToRefs(stationStore);
+
+const { hasPermission } = useUserAuthStore();
 
 const copyToClipboard = async () => {
 	try {
@@ -27,6 +35,23 @@ const copyToClipboard = async () => {
 	}
 };
 
+const showTab = _tab => {
+	tabs.value[`${_tab}-tab`].scrollIntoView({ block: "nearest" });
+	tab.value = _tab;
+};
+
+const addDj = userId => {
+	socket.dispatch("stations.addDj", station.value._id, userId, res => {
+		new Toast(res.message);
+	});
+};
+
+const removeDj = userId => {
+	socket.dispatch("stations.removeDj", station.value._id, userId, res => {
+		new Toast(res.message);
+	});
+};
+
 onMounted(async () => {
 	frontendDomain.value = await lofig.get("frontendDomain");
 	notesUri.value = encodeURI(`${frontendDomain.value}/assets/notes.png`);
@@ -35,58 +60,178 @@ onMounted(async () => {
 
 <template>
 	<div id="users">
-		<h5 class="has-text-centered">Total users: {{ userCount }}</h5>
-
-		<transition-group name="notification-box">
-			<h6
-				class="has-text-centered"
-				v-if="
-					users.loggedIn &&
-					users.loggedOut &&
-					((users.loggedIn.length === 1 &&
-						users.loggedOut.length === 0) ||
-						(users.loggedIn.length === 0 &&
-							users.loggedOut.length === 1))
-				"
-				key="only-me"
-			>
-				It's just you in the station!
-			</h6>
-			<h6
-				class="has-text-centered"
-				v-else-if="
-					users.loggedIn &&
-					users.loggedOut &&
-					users.loggedOut.length > 0
-				"
-				key="logged-out-users"
-			>
-				{{ users.loggedOut.length }}
-				{{ users.loggedOut.length > 1 ? "users are" : "user is" }}
-				logged-out.
-			</h6>
-		</transition-group>
-
-		<aside class="menu">
-			<ul class="menu-list scrollable-list">
-				<li v-for="user in users.loggedIn" :key="user.username">
-					<router-link
-						:to="{
-							name: 'profile',
-							params: { username: user.username }
-						}"
-						target="_blank"
+		<div class="tabs-container">
+			<div v-if="hasPermission('stations.update')" class="tab-selection">
+				<button
+					class="button is-default"
+					:ref="el => (tabs['active-tab'] = el)"
+					:class="{ selected: tab === 'active' }"
+					@click="showTab('active')"
+				>
+					Active
+				</button>
+				<button
+					class="button is-default"
+					:ref="el => (tabs['djs-tab'] = el)"
+					:class="{ selected: tab === 'djs' }"
+					@click="showTab('djs')"
+				>
+					DJs
+				</button>
+			</div>
+			<div class="tab" v-show="tab === 'active'">
+				<h5 class="has-text-centered">Total users: {{ userCount }}</h5>
+
+				<transition-group name="notification-box">
+					<h6
+						class="has-text-centered"
+						v-if="
+							users.loggedIn &&
+							users.loggedOut &&
+							((users.loggedIn.length === 1 &&
+								users.loggedOut.length === 0) ||
+								(users.loggedIn.length === 0 &&
+									users.loggedOut.length === 1))
+						"
+						key="only-me"
+					>
+						It's just you in the station!
+					</h6>
+					<h6
+						class="has-text-centered"
+						v-else-if="
+							users.loggedIn &&
+							users.loggedOut &&
+							users.loggedOut.length > 0
+						"
+						key="logged-out-users"
 					>
-						<profile-picture
-							:avatar="user.avatar"
-							:name="user.name || user.username"
-						/>
+						{{ users.loggedOut.length }}
+						{{
+							users.loggedOut.length > 1 ? "users are" : "user is"
+						}}
+						logged-out.
+					</h6>
+				</transition-group>
+
+				<aside class="menu">
+					<ul class="menu-list scrollable-list">
+						<li v-for="user in users.loggedIn" :key="user.username">
+							<router-link
+								:to="{
+									name: 'profile',
+									params: { username: user.username }
+								}"
+								target="_blank"
+							>
+								<profile-picture
+									:avatar="user.avatar"
+									:name="user.name || user.username"
+								/>
+
+								{{ user.name || user.username }}
+
+								<span
+									v-if="user._id === station.owner"
+									class="material-icons user-rank"
+									content="Station Owner"
+									v-tippy="{ theme: 'info' }"
+									>local_police</span
+								>
+								<span
+									v-else-if="
+										station.djs.find(
+											dj => dj._id === user._id
+										)
+									"
+									class="material-icons user-rank"
+									content="Station DJ"
+									v-tippy="{ theme: 'info' }"
+									>shield</span
+								>
+
+								<button
+									v-if="
+										hasPermission('stations.djs.add') &&
+										!station.djs.find(
+											dj => dj._id === user._id
+										) &&
+										station.owner !== user._id
+									"
+									class="button is-primary material-icons"
+									@click.prevent="addDj(user._id)"
+									content="Promote user to DJ"
+									v-tippy
+								>
+									add_moderator
+								</button>
+								<button
+									v-else-if="
+										hasPermission('stations.djs.remove') &&
+										station.djs.find(
+											dj => dj._id === user._id
+										)
+									"
+									class="button is-danger material-icons"
+									@click.prevent="removeDj(user._id)"
+									content="Demote user from DJ"
+									v-tippy
+								>
+									remove_moderator
+								</button>
+							</router-link>
+						</li>
+					</ul>
+				</aside>
+			</div>
+			<div
+				v-if="hasPermission('stations.update')"
+				class="tab"
+				v-show="tab === 'djs'"
+			>
+				<h5 class="has-text-centered">Station DJs</h5>
+				<h6 class="has-text-centered">
+					Add/remove DJs, who can manage the station and queue.
+				</h6>
+				<aside class="menu">
+					<ul class="menu-list scrollable-list">
+						<li v-for="dj in station.djs" :key="dj._id">
+							<router-link
+								:to="{
+									name: 'profile',
+									params: { username: dj.username }
+								}"
+								target="_blank"
+							>
+								<profile-picture
+									:avatar="dj.avatar"
+									:name="dj.name || dj.username"
+								/>
+
+								{{ dj.name || dj.username }}
 
-						{{ user.name || user.username }}
-					</router-link>
-				</li>
-			</ul>
-		</aside>
+								<span
+									class="material-icons user-rank"
+									content="Station DJ"
+									v-tippy="{ theme: 'info' }"
+									>shield</span
+								>
+
+								<button
+									v-if="hasPermission('stations.djs.remove')"
+									class="button is-danger material-icons"
+									@click.prevent="removeDj(dj._id)"
+									content="Demote user from DJ"
+									v-tippy
+								>
+									remove_moderator
+								</button>
+							</router-link>
+						</li>
+					</ul>
+				</aside>
+			</div>
+		</div>
 
 		<button
 			class="button is-primary tab-actionable-button"
@@ -114,6 +259,11 @@ onMounted(async () => {
 			color: var(--light-grey) !important;
 		}
 	}
+
+	.tabs-container .tab-selection .button {
+		background: var(--dark-grey) !important;
+		color: var(--white) !important;
+	}
 }
 
 .notification-box-enter-active,
@@ -130,55 +280,95 @@ onMounted(async () => {
 	margin-bottom: 20px;
 	border-radius: 0 0 @border-radius @border-radius;
 	max-height: 100%;
+	.tabs-container {
+		padding: 10px;
 
-	.menu {
-		padding: 0 10px;
-		margin-top: 20px;
-		width: 100%;
-		overflow: auto;
-		height: calc(100% - 20px - 40px);
+		.tab-selection {
+			display: flex;
+			overflow-x: auto;
+			margin-bottom: 20px;
+			.button {
+				border-radius: 0;
+				border: 0;
+				text-transform: uppercase;
+				font-size: 14px;
+				color: var(--dark-grey-3);
+				background-color: var(--light-grey-2);
+				flex-grow: 1;
+				height: 32px;
 
-		.menu-list {
-			padding: 0 10px;
-			margin-left: 0;
-		}
+				&:not(:first-of-type) {
+					margin-left: 5px;
+				}
+			}
 
-		li {
-			&:not(:first-of-type) {
-				margin-top: 10px;
+			.selected {
+				background-color: var(--primary-color) !important;
+				color: var(--white) !important;
+				font-weight: 600;
 			}
+		}
+		.tab {
+			.menu {
+				margin-top: 20px;
+				width: 100%;
+				overflow: auto;
+				height: calc(100% - 20px - 40px);
 
-			a {
-				display: flex;
-				align-items: center;
-				padding: 5px 10px;
-				border: 0.5px var(--light-grey-3) solid;
-				border-radius: @border-radius;
-				cursor: pointer;
-
-				&:hover {
-					background-color: var(--light-grey);
-					color: var(--black);
+				.menu-list {
+					margin-left: 0;
+					padding: 0;
 				}
 
-				.profile-picture {
-					margin-right: 10px;
-					width: 35px;
-					height: 35px;
-				}
+				li {
+					&:not(:first-of-type) {
+						margin-top: 10px;
+					}
+
+					a {
+						display: flex;
+						align-items: center;
+						padding: 5px 10px;
+						border: 0.5px var(--light-grey-3) solid;
+						border-radius: @border-radius;
+						cursor: pointer;
 
-				:deep(.profile-picture.using-initials span) {
-					font-size: calc(
-						35px / 5 * 2
-					); // 2/5th of .profile-picture height/width
+						&:hover {
+							background-color: var(--light-grey);
+							color: var(--black);
+						}
+
+						.profile-picture {
+							margin-right: 10px;
+							width: 36px;
+							height: 36px;
+						}
+
+						:deep(.profile-picture.using-initials span) {
+							font-size: calc(
+								36px / 5 * 2
+							); // 2/5th of .profile-picture height/width
+						}
+
+						.user-rank {
+							color: var(--primary-color);
+							font-size: 18px;
+							margin: 0 5px;
+						}
+
+						.button {
+							margin-left: auto;
+							font-size: 18px;
+							width: 36px;
+						}
+					}
 				}
 			}
-		}
-	}
 
-	h5 {
-		font-size: 20px;
-		margin-top: 20px;
+			h5 {
+				font-size: 20px;
+			}
+		}
 	}
 }
 </style>

+ 23 - 4
frontend/src/pages/Station/index.vue

@@ -169,7 +169,10 @@ const {
 	updateOwnCurrentSongRatings,
 	updateCurrentSongSkipVotes,
 	updateAutoRequestLock,
-	hasPermission
+	hasPermission,
+	addDj,
+	removeDj,
+	updatePermissions
 } = stationStore;
 
 // TODO fix this if it still has some use
@@ -798,7 +801,7 @@ const resetKeyboardShortcutsHelper = () => {
 	keyboardShortcutsHelper.value.resetBox();
 };
 const join = () => {
-	socket.dispatch("stations.join", stationIdentifier.value, res => {
+	socket.dispatch("stations.join", stationIdentifier.value, async res => {
 		if (res.status === "success") {
 			setTimeout(() => {
 				loading.value = false;
@@ -817,7 +820,7 @@ const join = () => {
 				isFavorited,
 				theme,
 				requests,
-				permissions
+				djs
 			} = res.data;
 
 			// change url to use station name instead of station id
@@ -839,7 +842,7 @@ const join = () => {
 				isFavorited,
 				theme,
 				requests,
-				permissions
+				djs
 			});
 
 			document.getElementsByTagName(
@@ -857,6 +860,8 @@ const join = () => {
 			updateUserCount(res.data.userCount);
 			updateUsers(res.data.users);
 
+			await updatePermissions();
+
 			socket.dispatch(
 				"stations.getStationAutofillPlaylistsById",
 				station.value._id,
@@ -1338,6 +1343,20 @@ onMounted(async () => {
 			updateIfStationIsFavorited({ isFavorited: false });
 	});
 
+	socket.on("event:station.djs.added", res => {
+		if (res.data.user._id === userId.value) updatePermissions();
+		addDj(res.data.user);
+	});
+
+	socket.on("event:station.djs.removed", res => {
+		if (res.data.user._id === userId.value) updatePermissions();
+		removeDj(res.data.user);
+	});
+
+	socket.on("keep.event:user.role.updated", () => {
+		updatePermissions();
+	});
+
 	if (JSON.parse(localStorage.getItem("muted"))) {
 		muted.value = true;
 		player.value.setVolume(0);

+ 27 - 5
frontend/src/stores/manageStation.ts

@@ -2,6 +2,7 @@ import { defineStore } from "pinia";
 import { Station } from "@/types/station";
 import { Playlist } from "@/types/playlist";
 import { CurrentSong, Song } from "@/types/song";
+import ws from "@/ws";
 
 export const useManageStationStore = props => {
 	const { modalUuid } = props;
@@ -17,7 +18,8 @@ export const useManageStationStore = props => {
 			blacklist: <Playlist[]>[],
 			songsList: <Song[]>[],
 			stationPaused: true,
-			currentSong: <CurrentSong>{}
+			currentSong: <CurrentSong>{},
+			permissions: {}
 		}),
 		actions: {
 			init({ stationId, sector }) {
@@ -44,6 +46,7 @@ export const useManageStationStore = props => {
 				this.songsList = [];
 				this.stationPaused = true;
 				this.currentSong = {};
+				this.permissions = {};
 			},
 			updateSongsList(songsList) {
 				this.songsList = songsList;
@@ -77,10 +80,29 @@ export const useManageStationStore = props => {
 				this.station.isFavorited = isFavorited;
 			},
 			hasPermission(permission) {
-				return !!(
-					this.station.permissions &&
-					this.station.permissions[permission]
-				);
+				return !!(this.permissions && this.permissions[permission]);
+			},
+			updatePermissions() {
+				return new Promise(resolve => {
+					ws.socket.dispatch(
+						"utils.getPermissions",
+						this.station._id,
+						res => {
+							this.permissions = res.data.permissions;
+							resolve(this.permissions);
+						}
+					);
+				});
+			},
+			addDj(user) {
+				this.station.djs.push(user);
+			},
+			removeDj(user) {
+				this.station.djs.forEach((dj, index) => {
+					if (dj._id === user._id) {
+						this.station.djs.splice(index, 1);
+					}
+				});
 			}
 		}
 	})();

+ 27 - 4
frontend/src/stores/station.ts

@@ -3,6 +3,7 @@ import { Playlist } from "@/types/playlist";
 import { Song, CurrentSong } from "@/types/song";
 import { Station } from "@/types/station";
 import { User } from "@/types/user";
+import ws from "@/ws";
 
 export const useStationStore = defineStore("station", {
 	state: () => ({
@@ -23,7 +24,8 @@ export const useStationStore = defineStore("station", {
 		noSong: true,
 		autofill: <Playlist[]>[],
 		blacklist: <Playlist[]>[],
-		mediaModalPlayingAudio: false
+		mediaModalPlayingAudio: false,
+		permissions: {}
 	}),
 	actions: {
 		joinStation(station) {
@@ -47,6 +49,7 @@ export const useStationStore = defineStore("station", {
 			this.noSong = true;
 			this.autofill = [];
 			this.blacklist = [];
+			this.permissions = {};
 		},
 		editStation(station) {
 			this.editing = { ...station };
@@ -133,9 +136,29 @@ export const useStationStore = defineStore("station", {
 			this.mediaModalPlayingAudio = mediaModalPlayingAudio;
 		},
 		hasPermission(permission) {
-			return !!(
-				this.station.permissions && this.station.permissions[permission]
-			);
+			return !!(this.permissions && this.permissions[permission]);
+		},
+		updatePermissions() {
+			return new Promise(resolve => {
+				ws.socket.dispatch(
+					"utils.getPermissions",
+					this.station._id,
+					res => {
+						this.permissions = res.data.permissions;
+						resolve(this.permissions);
+					}
+				);
+			});
+		},
+		addDj(user) {
+			this.station.djs.push(user);
+		},
+		removeDj(user) {
+			this.station.djs.forEach((dj, index) => {
+				if (dj._id === user._id) {
+					this.station.djs.splice(index, 1);
+				}
+			});
 		}
 	}
 });

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

@@ -232,7 +232,6 @@ 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) {
@@ -242,8 +241,20 @@ export const useUserAuthStore = defineStore("userAuth", {
 		updateUsername(username) {
 			this.username = username;
 		},
+		updateRole(role) {
+			this.role = role;
+			this.updatePermissions();
+		},
 		hasPermission(permission) {
 			return !!(this.permissions && this.permissions[permission]);
+		},
+		updatePermissions() {
+			return new Promise(resolve => {
+				ws.socket.dispatch("utils.getPermissions", res => {
+					this.permissions = res.data.permissions;
+					resolve(this.permissions);
+				});
+			});
 		}
 	}
 });

+ 2 - 0
frontend/src/types/station.ts

@@ -1,5 +1,6 @@
 import { Song } from "./song";
 import { Playlist } from "./playlist";
+import { User } from "./user";
 
 export interface Station {
 	_id: string;
@@ -30,4 +31,5 @@ export interface Station {
 	};
 	theme: string;
 	blacklist: Playlist[];
+	djs: User[];
 }

+ 2 - 0
frontend/src/ws.ts

@@ -189,6 +189,8 @@ export default {
 					pendingDispatches.forEach(cb => cb());
 					pendingDispatches = [];
 				}, 150); // small delay between readyState being 1 and the server actually receiving dispatches
+
+				userAuthStore.updatePermissions();
 			});
 		}
 	}