Browse Source

refactor: Implemented hasPermission on frontend

Owen Diffey 2 years ago
parent
commit
20339b39a8

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

@@ -1905,7 +1905,7 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	updatePrivacyAdmin: useHasPermission(
-		"playlists.updatePrivacyAdmin",
+		"playlists.updatePrivacy",
 		async function updatePrivacyAdmin(session, playlistId, privacy, cb) {
 			const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 

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

@@ -2,7 +2,7 @@ import async from "async";
 import mongoose from "mongoose";
 import config from "config";
 
-import { hasPermission, useHasPermission } from "../hooks/hasPermission";
+import { hasPermission, useHasPermission, getUserPermissions } from "../hooks/hasPermission";
 import isLoginRequired from "../hooks/loginRequired";
 
 // eslint-disable-next-line
@@ -862,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) => {
@@ -951,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) => {

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

@@ -5,9 +5,8 @@ import moduleManager from "../../index";
 
 const permissions = {};
 permissions.dj = {
-	"test.queue.add": true,
-	"test.queue.remove": false,
 	"stations.view": true,
+	"stations.view.manage": true,
 	"stations.skip": true,
 	"stations.pause": true,
 	"stations.resume": true,
@@ -23,13 +22,11 @@ permissions.dj = {
 };
 permissions.owner = {
 	...permissions.dj,
-	"test.queue.remove": true,
 	"stations.update": true,
 	"stations.remove": true
 };
 permissions.moderator = {
 	...permissions.owner,
-	"test.remove.other": false,
 	"songs.length": true,
 	"songs.getData": true,
 	"songs.getSongFromId": true,
@@ -61,12 +58,14 @@ permissions.moderator = {
 	"news.update": true,
 	"playlists.getData": true,
 	"playlists.searchOfficial": true,
-	"playlists.updatePrivacyAdmin": true,
+	"playlists.updatePrivacy": true,
+	"playlists.updateDisplayName": false,
 	"playlists.getPlaylist": true,
 	"playlists.repositionSong": true,
 	"playlists.addSongToPlaylist": true,
 	"playlists.addSetToPlaylist": true,
 	"playlists.removeSongFromPlaylist": true,
+	"playlists.view.others": true,
 	"punishments.getData": true,
 	"punishments.getPunishmentsForUser": true,
 	"punishments.findOne": true,
@@ -83,11 +82,11 @@ permissions.moderator = {
 	"stations.index.other": true,
 	"stations.create.official": true,
 	"youtube.getVideos": true,
-	"youtube.requestSetAdmin": true
+	"youtube.requestSetAdmin": true,
+	"admin.view": true
 };
 permissions.admin = {
 	...permissions.moderator,
-	"test.remove.other": true,
 	"songs.updateAll": true,
 	"songs.remove": true,
 	"songs.removeMany": true,
@@ -251,3 +250,76 @@ export const useHasPermission = (options, destination) =>
 			}
 		);
 	};
+
+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);
+			}
+		);
+	});
+};

+ 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;
@@ -606,9 +608,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 } });
 					})

+ 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";
@@ -33,7 +32,6 @@ const emit = defineEmits(["selected"]);
 
 const { socket } = useWebsocketsStore();
 const stationStore = useStationStore();
-const userAuthStore = useUserAuthStore();
 
 const tab = ref("current");
 const search = reactive({
@@ -59,7 +57,6 @@ const {
 	calculatePlaylistOrder
 } = useSortablePlaylists();
 
-const { loggedIn, role, userId } = storeToRefs(userAuthStore);
 const { autoRequest } = storeToRefs(stationStore);
 
 const manageStationStore = useManageStationStore(props);
@@ -96,6 +93,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();
@@ -141,11 +143,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;
 
@@ -510,7 +507,7 @@ onMounted(() => {
 								v-if="
 									featuredPlaylist.createdBy !== myUserId &&
 									(featuredPlaylist.privacy === 'public' ||
-										isAdmin())
+										hasPermission('playlists.view.others'))
 								"
 								@click="
 									openModal({
@@ -691,7 +688,8 @@ onMounted(() => {
 							<i
 								v-if="
 									playlist.createdBy !== myUserId &&
-									(playlist.privacy === 'public' || isAdmin())
+									(playlist.privacy === 'public' ||
+										hasPermission('playlists.view.others'))
 								"
 								@click="
 									openModal({
@@ -744,7 +742,6 @@ onMounted(() => {
 
 						<template #actions>
 							<quick-confirm
-								v-if="isOwnerOrAdmin()"
 								@confirm="deselectPlaylist(playlist._id)"
 							>
 								<i
@@ -773,7 +770,8 @@ onMounted(() => {
 							<i
 								v-if="
 									playlist.createdBy !== myUserId &&
-									(playlist.privacy === 'public' || isAdmin())
+									(playlist.privacy === 'public' ||
+										hasPermission('playlists.view.others'))
 								"
 								@click="
 									openModal({

+ 12 - 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(
@@ -19,11 +17,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);
@@ -59,15 +54,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.repositionSongInQueue"),
 	ghostClass: "draggable-list-ghost"
 }));
 
@@ -159,17 +154,21 @@ onUpdated(() => {
 						:song="element"
 						:requested-by="true"
 						:class="{
-							'item-draggable': isAdminOnly() || isOwnerOnly()
+							'item-draggable': hasPermission(
+								'stations.repositionSongInQueue'
+							)
 						}"
 						:disabled-actions="[]"
 						:ref="el => (songItems[`song-item-${index}`] = el)"
 					>
 						<template
-							v-if="isAdminOnly() || isOwnerOnly()"
+							v-if="
+								hasPermission('stations.repositionSongInQueue')
+							"
 							#tippyActions
 						>
 							<quick-confirm
-								v-if="isOwnerOnly() || isAdminOnly()"
+								v-if="hasPermission('stations.removeFromQueue')"
 								placement="left"
 								@confirm="removeFromQueue(element.youtubeId)"
 							>

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

@@ -40,7 +40,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();
 
@@ -239,7 +240,7 @@ onUnmounted(() => {
 								v-if="
 									loggedIn &&
 									song._id &&
-									userRole === 'admin' &&
+									hasPermission('songs.update') &&
 									disabledActions.indexOf('edit') === -1
 								"
 								class="material-icons edit-icon"

+ 20 - 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,7 @@ const unfavoriteStation = () => {
 			<!-- (Admin) Pause/Resume Button -->
 			<button
 				class="button is-danger"
-				v-if="isOwnerOrAdmin() && stationPaused"
+				v-if="hasPermission('stations.resume') && stationPaused"
 				@click="resumeStation()"
 			>
 				<i class="material-icons icon-with-button">play_arrow</i>
@@ -113,7 +117,7 @@ const unfavoriteStation = () => {
 			<button
 				class="button is-danger"
 				@click="pauseStation()"
-				v-if="isOwnerOrAdmin() && !stationPaused"
+				v-if="hasPermission('stations.pause') && !stationPaused"
 			>
 				<i class="material-icons icon-with-button">pause</i>
 				<span> Pause Station </span>
@@ -123,7 +127,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 +145,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>

+ 3 - 3
frontend/src/components/global/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"
 				>

+ 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.updatePrivacy" &&
+		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.updateDisplayName') &&
 				!(
 					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.updatePrivacy')">
 			<label class="label"> Change privacy </label>
 			<div class="control is-grouped input-with-button">
 				<div class="control is-expanded select">

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

@@ -62,16 +62,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.updatePrivacy" &&
+		hasPermission(permission));
 
 const dragOptions = computed(() => ({
 	animation: 200,
 	group: "songs",
-	disabled: !isEditable(),
+	disabled: !isEditable("playlists.repositionSong"),
 	ghostClass: "draggable-list-ghost"
 }));
 
@@ -85,11 +93,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];
@@ -166,7 +169,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();
@@ -309,13 +312,15 @@ onBeforeUnmount(() => {
 <template>
 	<modal
 		:title="
-			userId === playlist.createdBy ? 'Edit Playlist' : 'View Playlist'
+			isEditable('playlists.updatePrivacy')
+				? 'Edit Playlist'
+				: 'View Playlist'
 		"
 		:class="{
 			'edit-playlist-modal': true,
-			'view-only': !isEditable()
+			'view-only': !isEditable('playlists.updatePrivacy')
 		}"
-		:size="isEditable() ? 'wide' : null"
+		:size="isEditable('playlists.updatePrivacy') ? 'wide' : null"
 		:split="true"
 	>
 		<template #body>
@@ -333,11 +338,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.updatePrivacy')"
 						>
 							Settings
 						</button>
@@ -346,7 +347,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.addSongToPlaylist')"
 						>
 							Add Songs
 						</button>
@@ -357,7 +358,7 @@ onBeforeUnmount(() => {
 							}"
 							:ref="el => (tabs['import-playlists-tab'] = el)"
 							@click="showTab('import-playlists')"
-							v-if="isEditable()"
+							v-if="isEditable('playlists.addSetToPlaylist')"
 						>
 							Import Playlists
 						</button>
@@ -365,23 +366,19 @@ onBeforeUnmount(() => {
 					<settings
 						class="tab"
 						v-show="tab === 'settings'"
-						v-if="
-							userId === playlist.createdBy ||
-							isEditable() ||
-							(playlist.type === 'genre' && isAdmin())
-						"
+						v-if="isEditable('playlists.updatePrivacy')"
 						:modal-uuid="modalUuid"
 					/>
 					<add-songs
 						class="tab"
 						v-show="tab === 'add-songs'"
-						v-if="isEditable()"
+						v-if="isEditable('playlists.addSongToPlaylist')"
 						:modal-uuid="modalUuid"
 					/>
 					<import-playlists
 						class="tab"
 						v-show="tab === 'import-playlists'"
-						v-if="isEditable()"
+						v-if="isEditable('playlists.addSetToPlaylist')"
 						:modal-uuid="modalUuid"
 					/>
 				</div>
@@ -389,7 +386,7 @@ onBeforeUnmount(() => {
 
 			<div class="right-section">
 				<div id="rearrange-songs-section" class="section">
-					<div v-if="isEditable()">
+					<div v-if="isEditable('playlists.repositionSong')">
 						<h4 class="section-title">Rearrange Songs</h4>
 
 						<p class="section-description">
@@ -417,7 +414,9 @@ onBeforeUnmount(() => {
 									<song-item
 										:song="element"
 										:class="{
-											'item-draggable': isEditable()
+											'item-draggable': isEditable(
+												'playlists.repositionSong'
+											)
 										}"
 										:ref="
 											el =>
@@ -456,7 +455,9 @@ onBeforeUnmount(() => {
 												v-if="
 													userId ===
 														playlist.createdBy ||
-													isEditable()
+													isEditable(
+														'playlists.removeSongFromPlaylist'
+													)
 												"
 												placement="left"
 												@confirm="
@@ -474,7 +475,11 @@ onBeforeUnmount(() => {
 											</quick-confirm>
 											<i
 												class="material-icons"
-												v-if="isEditable() && index > 0"
+												v-if="
+													isEditable(
+														'playlists.repositionSong'
+													) && index > 0
+												"
 												@click="
 													moveSongToTop(
 														element,
@@ -487,7 +492,9 @@ onBeforeUnmount(() => {
 											>
 											<i
 												v-if="
-													isEditable() &&
+													isEditable(
+														'playlists.repositionSong'
+													) &&
 													playlistSongs.length - 1 !==
 														index
 												"
@@ -520,14 +527,22 @@ onBeforeUnmount(() => {
 		<template #footer>
 			<button
 				class="button is-default"
-				v-if="isOwner() || isAdmin() || playlist.privacy === 'public'"
+				v-if="
+					isOwner() ||
+					hasPermission('playlists.getPlaylist') ||
+					playlist.privacy === 'public'
+				"
 				@click="downloadPlaylist()"
 			>
 				Download Playlist
 			</button>
 			<div class="right">
 				<quick-confirm
-					v-if="playlist.type === 'station'"
+					v-if="
+						hasPermission(
+							'playlists.clearAndRefillStationPlaylist'
+						) && playlist.type === 'station'
+					"
 					@confirm="clearAndRefillStationPlaylist()"
 				>
 					<a class="button is-danger">
@@ -535,7 +550,11 @@ onBeforeUnmount(() => {
 					</a>
 				</quick-confirm>
 				<quick-confirm
-					v-if="playlist.type === 'genre'"
+					v-if="
+						hasPermission(
+							'playlists.clearAndRefillGenrePlaylist'
+						) && playlist.type === 'genre'
+					"
 					@confirm="clearAndRefillGenrePlaylist()"
 				>
 					<a class="button is-danger">
@@ -544,7 +563,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

@@ -33,7 +33,7 @@ const props = defineProps({
 const tabs = ref([]);
 
 const userAuthStore = useUserAuthStore();
-const { loggedIn, userId, role } = storeToRefs(userAuthStore);
+const { loggedIn } = storeToRefs(userAuthStore);
 
 const { socket } = useWebsocketsStore();
 
@@ -60,7 +60,8 @@ const {
 	updateStationPaused,
 	updateCurrentSong,
 	updateStation,
-	updateIsFavorited
+	updateIsFavorited,
+	hasPermission
 } = manageStationStore;
 
 const { closeCurrentModal } = useModalsStore();
@@ -71,20 +72,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.addToQueue")));
 
 const removeStation = () => {
 	socket.dispatch("stations.remove", stationId.value, res => {
@@ -107,8 +102,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();
 		}
 	}
@@ -126,7 +123,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
@@ -154,7 +151,7 @@ onMounted(() => {
 				}
 			);
 
-			if (isOwnerOrAdmin()) {
+			if (hasPermission("stations.getPlaylist")) {
 				socket.dispatch(
 					"playlists.getPlaylistForStation",
 					stationId.value,
@@ -311,7 +308,7 @@ onMounted(() => {
 		{ modalUuid: props.modalUuid }
 	);
 
-	if (isOwnerOrAdmin()) {
+	if (hasPermission("stations.getPlaylist")) {
 		socket.on(
 			"event:playlist.song.added",
 			res => {
@@ -381,7 +378,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
@@ -393,16 +390,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">
@@ -412,12 +413,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)"
@@ -436,7 +444,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' }"
@@ -446,7 +455,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)"
@@ -456,7 +465,7 @@ onBeforeUnmount(() => {
 							</button>
 						</div>
 						<settings
-							v-if="isOwnerOrAdmin()"
+							v-if="hasPermission('stations.update')"
 							class="tab"
 							v-show="tab === 'settings'"
 							:modal-uuid="modalUuid"
@@ -471,7 +480,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'"
@@ -485,7 +497,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'"
@@ -519,11 +531,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.removeFromQueue')"
+					@confirm="resetQueue()"
+				>
 					<a class="button is-danger">Reset queue</a>
 				</quick-confirm>
-				<quick-confirm @confirm="removeStation()">
+				<quick-confirm
+					v-if="hasPermission('stations.resetQueue')"
+					@confirm="removeStation()"
+				>
 					<button class="button is-danger">Delete station</button>
 				</quick-confirm>
 			</div>

+ 44 - 17
frontend/src/main.ts

@@ -173,57 +173,75 @@ const router = createRouter({
 			children: [
 				{
 					path: "songs",
-					component: () => import("@/pages/Admin/Songs/index.vue")
+					component: () => import("@/pages/Admin/Songs/index.vue"),
+					meta: { permissionRequired: "apis.joinAdminRoom.songs" }
 				},
 				{
 					path: "songs/import",
-					component: () => import("@/pages/Admin/Songs/Import.vue")
+					component: () => import("@/pages/Admin/Songs/Import.vue"),
+					meta: { permissionRequired: "apis.joinAdminRoom.import" }
 				},
 				{
 					path: "reports",
-					component: () => import("@/pages/Admin/Reports.vue")
+					component: () => import("@/pages/Admin/Reports.vue"),
+					meta: { permissionRequired: "apis.joinAdminRoom.reports" }
 				},
 				{
 					path: "stations",
-					component: () => import("@/pages/Admin/Stations.vue")
+					component: () => import("@/pages/Admin/Stations.vue"),
+					meta: { permissionRequired: "apis.joinAdminRoom.stations" }
 				},
 				{
 					path: "playlists",
-					component: () => import("@/pages/Admin/Playlists.vue")
+					component: () => import("@/pages/Admin/Playlists.vue"),
+					meta: { permissionRequired: "apis.joinAdminRoom.playlists" }
 				},
 				{
 					path: "users",
-					component: () => import("@/pages/Admin/Users/index.vue")
+					component: () => import("@/pages/Admin/Users/index.vue"),
+					meta: { permissionRequired: "apis.joinAdminRoom.users" }
 				},
 				{
 					path: "users/data-requests",
 					component: () =>
-						import("@/pages/Admin/Users/DataRequests.vue")
+						import("@/pages/Admin/Users/DataRequests.vue"),
+					meta: { permissionRequired: "apis.joinAdminRoom.users" }
 				},
 				{
 					path: "users/punishments",
 					component: () =>
-						import("@/pages/Admin/Users/Punishments.vue")
+						import("@/pages/Admin/Users/Punishments.vue"),
+					meta: {
+						permissionRequired: "apis.joinAdminRoom.punishments"
+					}
 				},
 				{
 					path: "news",
-					component: () => import("@/pages/Admin/News.vue")
+					component: () => import("@/pages/Admin/News.vue"),
+					meta: { permissionRequired: "apis.joinAdminRoom.news" }
 				},
 				{
 					path: "statistics",
-					component: () => import("@/pages/Admin/Statistics.vue")
+					component: () => import("@/pages/Admin/Statistics.vue"),
+					meta: {
+						permissionRequired: "apis.joinAdminRoom.statistics"
+					}
 				},
 				{
 					path: "youtube",
-					component: () => import("@/pages/Admin/YouTube/index.vue")
+					component: () => import("@/pages/Admin/YouTube/index.vue"),
+					meta: { permissionRequired: "apis.joinAdminRoom.youtube" }
 				},
 				{
 					path: "youtube/videos",
-					component: () => import("@/pages/Admin/YouTube/Videos.vue")
+					component: () => import("@/pages/Admin/YouTube/Videos.vue"),
+					meta: {
+						permissionRequired: "apis.joinAdminRoom.youtubeVideos"
+					}
 				}
 			],
 			meta: {
-				adminRequired: true
+				permissionRequired: "admin.view"
 			}
 		},
 		{
@@ -254,11 +272,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", query: "" });
-			else if (to.meta.adminRequired && userAuthStore.role !== "admin")
+			else if (
+				to.meta.permissionRequired &&
+				!userAuthStore.hasPermission(to.meta.permissionRequired)
+			)
 				next({ path: "/", query: "" });
 			else if (to.meta.guestsOnly && userAuthStore.loggedIn)
 				next({ path: "/", query: "" });
@@ -310,14 +335,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
 		});
 	});
 

+ 83 - 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 FloatingBox = defineAsyncComponent(
@@ -19,6 +20,8 @@ const router = useRouter();
 
 const { socket } = useWebsocketsStore();
 
+const { hasPermission } = useUserAuthStore();
+
 const currentTab = ref("");
 const siteSettings = ref({
 	logo: "",
@@ -177,7 +180,10 @@ onBeforeUnmount(() => {
 								<span>Minimise</span>
 							</div>
 							<div
-								v-if="sidebarActive"
+								v-if="
+									hasPermission('apis.joinAdminRoom.songs') &&
+									sidebarActive
+								"
 								class="sidebar-item with-children"
 								:class="{ 'is-active': childrenActive.songs }"
 							>
@@ -207,6 +213,11 @@ onBeforeUnmount(() => {
 										Songs
 									</router-link>
 									<router-link
+										v-if="
+											hasPermission(
+												'apis.joinAdminRoom.import'
+											)
+										"
 										class="sidebar-item-child"
 										to="/admin/songs/import"
 									>
@@ -215,7 +226,10 @@ onBeforeUnmount(() => {
 								</div>
 							</div>
 							<router-link
-								v-else
+								v-else-if="
+									hasPermission('apis.joinAdminRoom.users') &&
+									!sidebarActive
+								"
 								class="sidebar-item songs"
 								to="/admin/songs"
 								content="Songs"
@@ -228,6 +242,9 @@ onBeforeUnmount(() => {
 								<span>Songs</span>
 							</router-link>
 							<router-link
+								v-if="
+									hasPermission('apis.joinAdminRoom.reports')
+								"
 								class="sidebar-item reports"
 								to="/admin/reports"
 								content="Reports"
@@ -240,6 +257,9 @@ onBeforeUnmount(() => {
 								<span>Reports</span>
 							</router-link>
 							<router-link
+								v-if="
+									hasPermission('apis.joinAdminRoom.stations')
+								"
 								class="sidebar-item stations"
 								to="/admin/stations"
 								content="Stations"
@@ -252,6 +272,11 @@ onBeforeUnmount(() => {
 								<span>Stations</span>
 							</router-link>
 							<router-link
+								v-if="
+									hasPermission(
+										'apis.joinAdminRoom.playlists'
+									)
+								"
 								class="sidebar-item playlists"
 								to="/admin/playlists"
 								content="Playlists"
@@ -264,7 +289,10 @@ onBeforeUnmount(() => {
 								<span>Playlists</span>
 							</router-link>
 							<div
-								v-if="sidebarActive"
+								v-if="
+									hasPermission('apis.joinAdminRoom.users') &&
+									sidebarActive
+								"
 								class="sidebar-item with-children"
 								:class="{ 'is-active': childrenActive.users }"
 							>
@@ -308,7 +336,10 @@ onBeforeUnmount(() => {
 								</div>
 							</div>
 							<router-link
-								v-else
+								v-else-if="
+									hasPermission('apis.joinAdminRoom.users') &&
+									!sidebarActive
+								"
 								class="sidebar-item users"
 								to="/admin/users"
 								content="Users"
@@ -321,6 +352,7 @@ onBeforeUnmount(() => {
 								<span>Users</span>
 							</router-link>
 							<router-link
+								v-if="hasPermission('apis.joinAdminRoom.news')"
 								class="sidebar-item news"
 								to="/admin/news"
 								content="News"
@@ -333,6 +365,11 @@ onBeforeUnmount(() => {
 								<span>News</span>
 							</router-link>
 							<router-link
+								v-if="
+									hasPermission(
+										'apis.joinAdminRoom.statistics'
+									)
+								"
 								class="sidebar-item statistics"
 								to="/admin/statistics"
 								content="Statistics"
@@ -345,12 +382,28 @@ onBeforeUnmount(() => {
 								<span>Statistics</span>
 							</router-link>
 							<div
-								v-if="sidebarActive"
+								v-if="
+									(hasPermission(
+										'apis.joinAdminRoom.youtube'
+									) ||
+										hasPermission(
+											'apis.joinAdminRoom.youtubeVideos'
+										)) &&
+									sidebarActive
+								"
 								class="sidebar-item with-children"
 								:class="{ 'is-active': childrenActive.youtube }"
 							>
 								<span>
-									<router-link to="/admin/youtube">
+									<router-link
+										:to="`/admin/youtube${
+											hasPermission(
+												'apis.joinAdminRoom.youtube'
+											)
+												? ''
+												: '/videos'
+										}`"
+									>
 										<i class="material-icons"
 											>smart_display</i
 										>
@@ -371,12 +424,22 @@ onBeforeUnmount(() => {
 								</span>
 								<div class="sidebar-item-children">
 									<router-link
+										v-if="
+											hasPermission(
+												'apis.joinAdminRoom.youtube'
+											)
+										"
 										class="sidebar-item-child"
 										to="/admin/youtube"
 									>
 										YouTube
 									</router-link>
 									<router-link
+										v-if="
+											hasPermission(
+												'apis.joinAdminRoom.youtubeVideos'
+											)
+										"
 										class="sidebar-item-child"
 										to="/admin/youtube/videos"
 									>
@@ -385,9 +448,21 @@ onBeforeUnmount(() => {
 								</div>
 							</div>
 							<router-link
-								v-else
+								v-else-if="
+									(hasPermission(
+										'apis.joinAdminRoom.youtube'
+									) ||
+										hasPermission(
+											'apis.joinAdminRoom.youtubeVideos'
+										)) &&
+									!sidebarActive
+								"
 								class="sidebar-item youtube"
-								to="/admin/youtube"
+								:to="`/admin/youtube${
+									hasPermission('apis.joinAdminRoom.youtube')
+										? ''
+										: '/videos'
+								}`"
 								content="YouTube"
 								v-tippy="{
 									theme: 'info',

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

@@ -14,7 +14,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();
 
@@ -31,10 +32,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(() => {
@@ -117,7 +114,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.addToQueue"))));
 
 const favoriteStation = stationId => {
 	socket.dispatch("stations.favoriteStation", stationId, res => {
@@ -312,7 +310,7 @@ onMounted(async () => {
 		ctrl: true,
 		alt: true,
 		handler: () => {
-			if (isAdmin())
+			if (hasPermission("stations.index.other"))
 				if (route.query.adminFilter === undefined)
 					router.push({
 						query: {
@@ -417,7 +415,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({
@@ -681,7 +684,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({

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

@@ -27,7 +27,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 => {
@@ -89,12 +90,15 @@ onMounted(() => {
 				</div>
 				<div
 					class="buttons"
-					v-if="myUserId === userId || role === 'admin'"
+					v-if="
+						myUserId === userId ||
+						hasPermission('apis.joinAdminRoom.users')
+					"
 				>
 					<router-link
 						:to="`/admin/users?userId=${user._id}`"
 						class="button is-primary"
-						v-if="role === 'admin'"
+						v-if="hasPermission('apis.joinAdminRoom.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.addToQueue")));
 
 watch(
 	() => station.value.requests,

+ 29 - 15
frontend/src/pages/Station/index.vue

@@ -161,17 +161,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(
@@ -813,7 +810,8 @@ const join = () => {
 				type,
 				isFavorited,
 				theme,
-				requests
+				requests,
+				permissions
 			} = res.data;
 
 			// change url to use station name instead of station id
@@ -834,7 +832,8 @@ const join = () => {
 				type,
 				isFavorited,
 				theme,
-				requests
+				requests,
+				permissions
 			});
 
 			document.getElementsByTagName(
@@ -882,7 +881,10 @@ const join = () => {
 				}
 			});
 
-			if (isOwnerOrAdmin()) {
+			if (
+				hasPermission("stations.pause") &&
+				hasPermission("stations.resume")
+			)
 				keyboardShortcuts.registerShortcut("station.pauseResume", {
 					keyCode: 32, // Spacebar
 					shift: false,
@@ -895,6 +897,7 @@ const join = () => {
 					}
 				});
 
+			if (hasPermission("stations.skip"))
 				keyboardShortcuts.registerShortcut("station.skipStation", {
 					keyCode: 39, // Right arrow key
 					shift: false,
@@ -905,7 +908,6 @@ const join = () => {
 						skipStation();
 					}
 				});
-			}
 
 			keyboardShortcuts.registerShortcut("station.lowerVolumeLarge", {
 				keyCode: 40, // Down arrow key
@@ -1171,15 +1173,15 @@ onMounted(async () => {
 
 	ms.setListeners(0, {
 		play: () => {
-			if (isOwnerOrAdmin()) resumeStation();
+			if (hasPermission("stations.resume")) resumeStation();
 			else resumeLocalStation();
 		},
 		pause: () => {
-			if (isOwnerOrAdmin()) pauseStation();
+			if (hasPermission("stations.pause")) pauseStation();
 			else pauseLocalStation();
 		},
 		nexttrack: () => {
-			if (isOwnerOrAdmin()) skipStation();
+			if (hasPermission("stations.skip")) skipStation();
 			else voteSkipStation();
 		}
 	});
@@ -1290,7 +1292,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 {
@@ -2045,12 +2047,24 @@ onBeforeUnmount(() => {
 		>
 			<template #body>
 				<div>
-					<div v-if="isOwnerOrAdmin()">
+					<div
+						v-if="
+							hasPermission('stations.resume') ||
+							hasPermission('stations.pause') ||
+							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.resume') ||
+							hasPermission('stations.pause') ||
+							hasPermission('stations.skip')
+						"
+					/>
 					<div>
 						<span class="biggest"><b>Volume</b></span>
 						<span

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

@@ -71,6 +71,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

@@ -123,6 +123,11 @@ export const useStationStore = defineStore("station", {
 					this.autoRequest.splice(index, 1);
 				}
 			});
+		},
+		hasPermission(permission) {
+			return !!(
+				this.station.permissions && this.station.permissions[permission]
+			);
 		}
 	}
 });

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

@@ -15,7 +15,8 @@ export const useUserAuthStore = defineStore("userAuth", {
 		userId: "",
 		banned: false,
 		ban: {},
-		gotData: false
+		gotData: false,
+		permissions: {}
 	}),
 	actions: {
 		register(user) {
@@ -228,6 +229,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) {
@@ -236,6 +238,9 @@ export const useUserAuthStore = defineStore("userAuth", {
 		},
 		updateUsername(username) {
 			this.username = username;
+		},
+		hasPermission(permission) {
+			return !!(this.permissions && this.permissions[permission]);
 		}
 	}
 });