Przeglądaj źródła

Merge branch 'v3.8.0'

Owen Diffey 2 lat temu
rodzic
commit
6aae737bcc
100 zmienionych plików z 7311 dodań i 4813 usunięć
  1. 1 1
      .env.example
  2. 40 0
      .github/workflows/automated-tests.yml
  3. 2 2
      .github/workflows/build-lint.yml
  4. 1 0
      .gitignore
  5. 1 1
      .wiki/Configuration.md
  6. 72 0
      CHANGELOG.md
  7. 9 6
      README.md
  8. 3 3
      backend/Dockerfile
  9. 1 1
      backend/logic/actions/activities.js
  10. 58 38
      backend/logic/actions/apis.js
  11. 48 45
      backend/logic/actions/dataRequests.js
  12. 0 52
      backend/logic/actions/hooks/adminRequired.js
  13. 0 7
      backend/logic/actions/hooks/index.js
  14. 0 71
      backend/logic/actions/hooks/ownerRequired.js
  15. 100 98
      backend/logic/actions/media.js
  16. 90 85
      backend/logic/actions/news.js
  17. 674 291
      backend/logic/actions/playlists.js
  18. 204 195
      backend/logic/actions/punishments.js
  19. 93 87
      backend/logic/actions/reports.js
  20. 136 132
      backend/logic/actions/songs.js
  21. 346 308
      backend/logic/actions/stations.js
  22. 343 255
      backend/logic/actions/users.js
  23. 36 3
      backend/logic/actions/utils.js
  24. 351 357
      backend/logic/actions/youtube.js
  25. 11 14
      backend/logic/api.js
  26. 1 1
      backend/logic/app.js
  27. 1 1
      backend/logic/db/index.js
  28. 1 1
      backend/logic/db/schemas/playlist.js
  29. 1 0
      backend/logic/db/schemas/station.js
  30. 2 2
      backend/logic/db/schemas/user.js
  31. 1 0
      backend/logic/db/schemas/youtubeVideo.js
  32. 286 0
      backend/logic/hooks/hasPermission.js
  33. 1 1
      backend/logic/hooks/loginRequired.js
  34. 55 0
      backend/logic/migration/migrations/migration23.js
  35. 2 0
      backend/logic/playlists.js
  36. 192 102
      backend/logic/stations.js
  37. 1 0
      backend/logic/tasks.js
  38. 2 1
      backend/logic/youtube.js
  39. 248 242
      backend/package-lock.json
  40. 17 17
      backend/package.json
  41. 3 1
      docker-compose.yml
  42. 6 0
      frontend/.eslintrc
  43. 5 5
      frontend/Dockerfile
  44. 1 9
      frontend/dist/config/template.json
  45. 557 182
      frontend/package-lock.json
  46. 31 19
      frontend/package.json
  47. 95 112
      frontend/src/App.vue
  48. 0 47
      frontend/src/auth.ts
  49. 2 15
      frontend/src/aw.ts
  50. 6 5
      frontend/src/classes/ListenerHandler.class.ts
  51. 240 0
      frontend/src/classes/SocketHandler.class.ts
  52. 139 0
      frontend/src/classes/__mocks__/SocketHandler.class.ts
  53. 2 2
      frontend/src/components/ActivityItem.vue
  54. 49 12
      frontend/src/components/AddToPlaylistDropdown.vue
  55. 56 64
      frontend/src/components/AdvancedTable.vue
  56. 46 0
      frontend/src/components/ChristmasLights.spec.ts
  57. 1 1
      frontend/src/components/FloatingBox.vue
  58. 12 0
      frontend/src/components/InfoIcon.spec.ts
  59. 39 0
      frontend/src/components/InputHelpBox.spec.ts
  60. 190 0
      frontend/src/components/LongJobs.spec.ts
  61. 31 31
      frontend/src/components/LongJobs.vue
  62. 17 14
      frontend/src/components/MainHeader.vue
  63. 63 0
      frontend/src/components/Modal.spec.ts
  64. 4 14
      frontend/src/components/Modal.vue
  65. 4 2
      frontend/src/components/ModalManager.vue
  66. 53 56
      frontend/src/components/PlaylistTabBase.vue
  67. 18 3
      frontend/src/components/PunishmentItem.vue
  68. 11 14
      frontend/src/components/Queue.vue
  69. 0 4
      frontend/src/components/ReportInfoItem.vue
  70. 4 2
      frontend/src/components/Request.vue
  71. 1 0
      frontend/src/components/RunJobDropdown.vue
  72. 1 1
      frontend/src/components/SaveButton.vue
  73. 16 12
      frontend/src/components/SongItem.vue
  74. 27 15
      frontend/src/components/StationInfoBox.vue
  75. 8 7
      frontend/src/components/UserLink.vue
  76. 21 0
      frontend/src/components/__snapshots__/Modal.spec.ts.snap
  77. 18 27
      frontend/src/components/modals/BulkActions.vue
  78. 239 0
      frontend/src/components/modals/BulkEditPlaylist.vue
  79. 5 14
      frontend/src/components/modals/Confirm.vue
  80. 21 13
      frontend/src/components/modals/CreatePlaylist.vue
  81. 4 13
      frontend/src/components/modals/CreateStation.vue
  82. 81 91
      frontend/src/components/modals/EditNews.vue
  83. 5 5
      frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue
  84. 2 2
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  85. 121 49
      frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue
  86. 65 49
      frontend/src/components/modals/EditPlaylist/index.vue
  87. 28 17
      frontend/src/components/modals/EditSong/Tabs/Discogs.vue
  88. 3 3
      frontend/src/components/modals/EditSong/Tabs/Reports.vue
  89. 3 7
      frontend/src/components/modals/EditSong/Tabs/Songs.vue
  90. 10 9
      frontend/src/components/modals/EditSong/Tabs/Youtube.vue
  91. 350 587
      frontend/src/components/modals/EditSong/index.vue
  92. 260 136
      frontend/src/components/modals/EditUser.vue
  93. 47 22
      frontend/src/components/modals/ImportAlbum.vue
  94. 131 111
      frontend/src/components/modals/ManageStation/Settings.vue
  95. 337 251
      frontend/src/components/modals/ManageStation/index.vue
  96. 3 7
      frontend/src/components/modals/RemoveAccount.vue
  97. 222 177
      frontend/src/components/modals/Report.vue
  98. 39 42
      frontend/src/components/modals/ViewApiRequest.vue
  99. 30 39
      frontend/src/components/modals/ViewPunishment.vue
  100. 97 73
      frontend/src/components/modals/ViewReport.vue

+ 1 - 1
.env.example

@@ -16,7 +16,7 @@ MONGO_ROOT_PASSWORD=PASSWORD_HERE
 MONGO_USER_USERNAME=musare
 MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
 MONGO_DATA_LOCATION=.db
-MONGO_VERSION=5.0
+MONGO_VERSION=6
 
 REDIS_HOST=127.0.0.1
 REDIS_PORT=6379

+ 40 - 0
.github/workflows/automated-tests.yml

@@ -0,0 +1,40 @@
+name: Musare Automated Tests
+
+on: [ push, pull_request, workflow_dispatch ]
+
+env:
+    COMPOSE_PROJECT_NAME: musare
+    RESTART_POLICY: unless-stopped
+    CONTAINER_MODE: prod
+    BACKEND_HOST: 127.0.0.1
+    BACKEND_PORT: 8080
+    FRONTEND_HOST: 127.0.0.1
+    FRONTEND_PORT: 80
+    FRONTEND_MODE: prod
+    MONGO_HOST: 127.0.0.1
+    MONGO_PORT: 27017
+    MONGO_ROOT_PASSWORD: PASSWORD_HERE
+    MONGO_USER_USERNAME: musare
+    MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
+    MONGO_DATA_LOCATION: .db
+    MONGO_VERSION: 5.0
+    REDIS_HOST: 127.0.0.1
+    REDIS_PORT: 6379
+    REDIS_PASSWORD: PASSWORD
+    REDIS_DATA_LOCATION: .redis
+
+jobs:
+    tests:
+        runs-on: ubuntu-latest
+        steps:
+            - uses: actions/checkout@v3
+            - name: Build Musare
+              run: |
+                  cp .env.example .env
+                  cp backend/config/template.json backend/config/default.json
+                  cp frontend/dist/config/template.json frontend/dist/config/default.json
+                  ./musare.sh build
+            - name: Start Musare
+              run: ./musare.sh start
+            - name: Test Frontend
+              run: ./musare.sh test frontend

+ 2 - 2
.github/workflows/build-lint.yml

@@ -42,7 +42,7 @@ jobs:
               run: ./musare.sh typescript backend
             - name: Frontend Lint
               run: ./musare.sh lint frontend
-            - name: Frontend Typescript
-              run: ./musare.sh typescript frontend
+#            - name: Frontend Typescript
+#              run: ./musare.sh typescript frontend
             - name: Docs Lint
               run: ./musare.sh lint docs

+ 1 - 0
.gitignore

@@ -29,6 +29,7 @@ frontend/bundle-report.html
 frontend/node_modules/
 frontend/build/
 frontend/dist/config/default.json
+frontend/src/coverage/
 
 npm
 node_modules

+ 1 - 1
.wiki/Configuration.md

@@ -128,7 +128,7 @@ application within the container is listening on `21017`.
 | `MONGO_USER_USERNAME` | Application username for MongoDB. |
 | `MONGO_USER_PASSWORD` | Application password for MongoDB. |
 | `MONGO_DATA_LOCATION` | The location where MongoDB stores its data. Usually the `.db` folder inside the `Musare` folder. |
-| `MONGO_VERSION` | The MongoDB version to use for scripts and docker-compose. Must be numerical. Currently supported MongoDB versions are 4.0, 4.2, 4.4 and 5.0. |
+| `MONGO_VERSION` | The MongoDB version to use for scripts and docker-compose. Must be numerical. Currently supported MongoDB versions are 4.0+. Always backup before changing this value. |
 | `REDIS_HOST` | Redis container host. |
 | `REDIS_PORT` | Redis container port. |
 | `REDIS_PASSWORD` | Redis password. |

+ 72 - 0
CHANGELOG.md

@@ -1,5 +1,77 @@
 # Changelog
 
+## [v3.8.0] - 2022-11-11
+
+This release includes all changes from v3.8.0-rc1 and v3.8.0-rc2.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+## [v3.8.0-rc2] - 2022-10-31
+
+This release includes all changes from v3.8.0-rc1, in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+
+- feat: Add/remove media to/from admin playlist from admin/songs/import
+
+### Changed
+
+- refactor: Do not send ActivtyWatch watch event when video is buffering
+- refactor: Include playback rate in ActivtyWatch watch event
+
+### Fixed
+
+- fix: Toggling night mode does not update other tabs if logged out
+- fix: User not removed as DJ from station on deletion
+- fix: Clicking view YouTube video in song item does not close actions tippy
+- fix: ActivityWatch integration event started at was broken
+- fix: AddToPlaylistDropdown missing song added and removed event handling
+- fix: User logged out after removing another user
+- fix: Paused station elapsed duration incorrectly set
+
+## [v3.8.0-rc1] - 2022-10-16
+
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+
+- feat: Added moderator user role
+- feat: Added station DJ role
+- feat: Started implementing frontend component and unit testing
+- feat: Started migrating raw text to i18n-backed locales
+- feat: Completed implementing confirmation of closing modal with unsaved changes
+- feat: Added confirmation of saving form if source data has been updated
+- feat: Added support for docker compose v2 to musare.sh
+- feat: Store and display YouTube video upload date for newly created videos
+- feat: Added admin playlist type and ability to add/remove media
+in bulk from admin pages
+
+### Changed
+
+- refactor: Replaced admin and owner authentication with permission nodes
+- refactor: On user role change ensure user is still authorized to view route
+and generally improved handling
+- refactor: Made vote skip toggleable
+- refactor: Refactored CustomWebSocket into SocketHandler
+and improved socket connection handling
+- refactor: Added stage to musare.sh update command to
+update itself before continuing with update
+
+### Fixed
+
+- fix: Unable to update with musare.sh if git pull fails
+- fix: musare.sh update command does not pull docker images
+- fix: Edit Song modal does not close on song deletion if not in bulk mode
+- fix: Opening and closing modal will reset scroll position
+- fix: Invalid TypeScript in frontend
+- fix: Stations can pick up other stations current song
+and/or become out of sync after socket reconnection
+- fix: Site becomes unusable upon socket reconnection
+- fix: Profile page activity sets not loaded on scroll
+- fix: Adding/removing media from liked/disliked playlist does not emit ratings update
+- fix: Edit Song parsing YouTube duration as int rather than float
+- fix: Updating YouTube ID in Edit Song does not always update duration
+
 ## [v3.7.1] - 2022-09-02
 
 Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).

+ 9 - 6
README.md

@@ -24,7 +24,7 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
 ## Features
 
 - **Playlists**
-  - User created playlists
+  - User and admin created playlists
   - Automatically generated playlists for genres
   - Privacy configuration
   - Liked and Disliked songs playlists per user
@@ -32,12 +32,13 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
   - Add songs from verified catalogue or YouTube videos
   - Ability to download in JSON format
 - **Stations**
+  - DJs - Allow other users to manage the station queue
   - Requests - Toggleable module to allow users to add songs to the queue
     - Configurable access level and per user request limit
     - Automatically request songs from selected playlists
     - Ability to search for songs from verified catalogue or YouTube videos
-  - Autofill - Toggleable module to allow owners to configure automatic filling
-  of the queue from selected playlists
+  - Autofill - Toggleable module to allow owners or DJs to configure automatic
+  filling of the queue from selected playlists
     - Configurable song limit
     - Play mode option to randomly play many playlists, or sequentially play one
     playlist
@@ -49,9 +50,9 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
   - Official stations controlled by admins
   - User created and controlled stations
   - Pause playback just in local session
-  - Station-wide pausing by admins or owners
+  - Station-wide pausing by admins, owners or DJs
   - Vote to skip songs
-  - Force skipping song by admins or owners
+  - Force skipping song by admins, owners or DJs
 - **Song Management**
   - Verify songs to allow them to be searched for and added to automatically
   generated genre playlists
@@ -75,6 +76,7 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
   - Password reset
   - Data deletion management
   - ActivityWatch integration
+  - Admin and moderator roles
 - **Punishments**
   - Ban users
   - Ban IPs
@@ -85,10 +87,11 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
 - **Administration**
   - Admin area to manage instance
   - Configurable data tables
-    - Reorder, resize, sort by and toggle visibilty of columns
+    - Reorder, resize, sort by and toggle visibility of columns
     - Advanced queries
   - Bulk management
   - View backend statistics
+  - Limited administration privileges granted to moderators
 
 ---
 

+ 3 - 3
backend/Dockerfile

@@ -1,4 +1,4 @@
-FROM node:16.15 AS backend_node_modules
+FROM node:18 AS backend_node_modules
 
 RUN mkdir -p /opt/app
 WORKDIR /opt/app
@@ -8,12 +8,12 @@ COPY package-lock.json /opt/app/package-lock.json
 
 RUN npm install --silent
 
-FROM node:16.15 AS musare_backend
+FROM node:18 AS musare_backend
 
 ARG CONTAINER_MODE=prod
 ENV CONTAINER_MODE=${CONTAINER_MODE}
 
-RUN mkdir -p /opt/app
+RUN mkdir -p /opt/app /opt/types
 WORKDIR /opt/app
 
 COPY . /opt/app

+ 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";

+ 58 - 38
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 => {
@@ -124,31 +125,43 @@ export default {
 	 * @param {Function} cb - callback
 	 */
 	joinRoom(session, room, cb) {
-		if (
-			room === "home" ||
-			room === "news" ||
-			room.startsWith("profile.") ||
-			room.startsWith("manage-station.") ||
-			room.startsWith("edit-song.") ||
-			room.startsWith("view-report.") ||
-			room.startsWith("edit-user.") ||
-			room.startsWith("view-api-request.") ||
-			room.startsWith("view-youtube-video.") ||
-			room.startsWith("view-punishment.") ||
-			room === "import-album" ||
-			room === "edit-songs"
-		) {
-			WSModule.runJob("SOCKET_JOIN_ROOM", {
-				socketId: session.socketId,
-				room
-			})
-				.then(() => {})
-				.catch(err => {
-					this.log("ERROR", `Joining room failed: ${err.message}`);
-				});
-		}
-
-		cb({ status: "success", message: "Successfully joined room." });
+		const roomName = room.split(".")[0];
+		// const roomId = room.split(".")[1];
+		const rooms = {
+			home: null,
+			news: null,
+			profile: null,
+			"view-youtube-video": null,
+			"manage-station": null,
+			// "manage-station": "stations.view",
+			"edit-song": "songs.update",
+			"edit-songs": "songs.update",
+			"import-album": "songs.update",
+			// "edit-playlist": "playlists.update",
+			"view-report": "reports.get",
+			"edit-user": "users.update",
+			"view-api-request": "youtube.getApiRequest",
+			"view-punishment": "punishments.get"
+		};
+		const join = (status, error) => {
+			if (status === "success")
+				WSModule.runJob("SOCKET_JOIN_ROOM", {
+					socketId: session.socketId,
+					room
+				})
+					.then(() => cb({ status: "success", message: "Successfully joined room." }))
+					.catch(err => join("error", err.message));
+			else {
+				this.log("ERROR", `Joining room failed: ${error}`);
+				cb({ status: "error", message: error });
+			}
+		};
+		if (rooms[roomName] === null) join("success");
+		else if (rooms[roomName])
+			hasPermission(rooms[roomName], session)
+				.then(() => join("success"))
+				.catch(err => join("error", err));
+		else join("error", "Room not found");
 	},
 
 	/**
@@ -188,7 +201,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" ||
@@ -200,18 +213,25 @@ export default {
 			page === "punishments" ||
 			page === "youtube" ||
 			page === "youtubeVideos" ||
-			page === "import"
+			page === "import" ||
+			page === "dataRequests"
 		) {
-			WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session.socketId }).then(() => {
-				WSModule.runJob("SOCKET_JOIN_ROOM", {
-					socketId: session.socketId,
-					room: `admin.${page}`
-				});
-			});
+			hasPermission(`admin.view.${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

+ 48 - 45
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";
@@ -19,7 +19,7 @@ CacheModule.runJob("SUB", {
 
 		dataRequestModel.findOne({ _id: dataRequestId }, (err, dataRequest) => {
 			WSModule.runJob("EMIT_TO_ROOM", {
-				room: "admin.users",
+				room: "admin.dataRequests",
 				args: ["event:admin.dataRequests.updated", { data: { dataRequest } }]
 			});
 		});
@@ -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.dataRequests",
+		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(

Plik diff jest za duży
+ 674 - 291
backend/logic/actions/playlists.js


+ 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();

Plik diff jest za duży
+ 346 - 308
backend/logic/actions/stations.js


+ 343 - 255
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";
@@ -169,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 => {
@@ -245,71 +257,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.get",
+		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
@@ -365,7 +380,12 @@ export default {
 					});
 				},
 
+				// remove user as station DJ
 				next => {
+					stationModel.updateMany({ djs: session.userId }, { $pull: { djs: session.userId } }, next);
+				},
+
+				(res, next) => {
 					playlistModel.findOne({ createdBy: session.userId, type: "user-liked" }, next);
 				},
 
@@ -541,7 +561,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);
@@ -591,7 +611,12 @@ export default {
 					});
 				},
 
+				// remove user as station DJ
 				next => {
+					stationModel.updateMany({ djs: userId }, { $pull: { djs: userId } }, next);
+				},
+
+				(res, next) => {
 					playlistModel.findOne({ createdBy: userId, type: "user-liked" }, next);
 				},
 
@@ -647,7 +672,7 @@ export default {
 				(res, next) => {
 					CacheModule.runJob("PUB", {
 						channel: "user.removeSessions",
-						value: session.userId
+						value: userId
 					});
 
 					async.waterfall(
@@ -670,7 +695,6 @@ export default {
 
 							(keys, sessions, next) => {
 								// temp fix, need to wait properly for the SUB/PUB refactor (on wekan)
-								const { userId } = session;
 								setTimeout(
 									() =>
 										async.each(
@@ -1233,17 +1257,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 => {
@@ -1850,7 +1870,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)
@@ -1979,14 +1999,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.");
@@ -2072,14 +2091,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.restricted", 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.");
@@ -2183,14 +2201,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.");
@@ -2254,14 +2271,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.");
@@ -2325,14 +2341,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.");
@@ -2390,14 +2405,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.");
@@ -2453,61 +2467,70 @@ 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) {
-		newRole = newRole.toLowerCase();
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+	updateRole: useHasPermission(
+		"users.update.restricted",
+		async function updateRole(session, updatingUserId, newRole, cb) {
+			newRole = newRole.toLowerCase();
+			const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+			async.waterfall(
+				[
+					next => {
+						userModel.findOne({ _id: updatingUserId }, next);
+					},
 
-		async.waterfall(
-			[
-				next => {
-					userModel.findOne({ _id: updatingUserId }, next);
-				},
+					(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(null, user);
+					},
 
-				(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();
-				},
-				next => {
-					userModel.updateOne(
-						{ _id: updatingUserId },
-						{ $set: { role: newRole } },
-						{ runValidators: true },
-						next
-					);
-				}
-			],
-			async err => {
-				if (err && err !== true) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					(user, next) => {
+						userModel.updateOne(
+							{ _id: updatingUserId },
+							{ $set: { role: newRole } },
+							{ runValidators: true },
+							err => next(err, user)
+						);
+					}
+				],
+				async (err, user) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+						this.log(
+							"ERROR",
+							"UPDATE_ROLE",
+							`User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`
+						);
+
+						return cb({ status: "error", message: err });
+					}
 
 					this.log(
-						"ERROR",
+						"SUCCESS",
 						"UPDATE_ROLE",
-						`User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`
+						`User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`
 					);
 
-					return cb({ status: "error", message: err });
-				}
-
-				this.log(
-					"SUCCESS",
-					"UPDATE_ROLE",
-					`User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`
-				);
+					CacheModule.runJob("PUB", {
+						channel: "user.updated",
+						value: { userId: updatingUserId }
+					});
 
-				CacheModule.runJob("PUB", {
-					channel: "user.updated",
-					value: { userId: updatingUserId }
-				});
+					CacheModule.runJob("PUB", {
+						channel: "user.updateRole",
+						value: { user }
+					});
 
-				return cb({
-					status: "success",
-					message: "Role successfully updated."
-				});
-			}
-		);
-	}),
+					return cb({
+						status: "success",
+						message: "Role successfully updated."
+					});
+				}
+			);
+		}
+	),
 
 	/**
 	 * Updates a user's password
@@ -2984,73 +3007,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
@@ -3171,48 +3197,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
@@ -3223,7 +3252,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 => {
@@ -3317,5 +3346,64 @@ export default {
 				});
 			}
 		);
+	}),
+
+	/**
+	 * Search for a user by username or name
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} page - page
+	 * @param {Function} cb - gets called with the result
+	 */
+	search: isLoginRequired(async function search(session, query, page, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					const findQuery = {
+						$or: [{ name: new RegExp(`${query}`, "i"), username: new RegExp(`${query}`, "i") }]
+					};
+					const pageSize = 15;
+					const skipAmount = pageSize * (page - 1);
+
+					userModel.find(findQuery).count((err, count) => {
+						if (err) next(err);
+						else {
+							userModel
+								.find(findQuery, { _id: true, name: true, username: true, avatar: true })
+								.skip(skipAmount)
+								.limit(pageSize)
+								.exec((err, users) => {
+									if (err) next(err);
+									else {
+										next(null, {
+											users,
+											page,
+											pageSize,
+											skipAmount,
+											count
+										});
+									}
+								});
+						}
+					});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "USERS_SEARCH", `Searching users failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "USERS_SEARCH", "Searching users successful.");
+				return cb({ status: "success", data });
+			}
+		);
 	})
 };

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

@@ -1,6 +1,6 @@
 import async from "async";
 
-import { isAdminRequired } from "./hooks";
+import { useHasPermission, getUserPermissions } 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 => {
@@ -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 } });
+			}
+		);
 	}
 };

+ 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 });

+ 1 - 1
backend/logic/app.js

@@ -502,7 +502,7 @@ class _AppModule extends CoreClass {
 
 						this.log("INFO", "VERIFY_EMAIL", `Successfully verified email.`);
 
-						return res.redirect(`${config.get("domain")}?msg=Thank you for verifying your email`);
+						return res.redirect(`${config.get("domain")}?toast=Thank you for verifying your email`);
 					}
 				);
 			});

+ 1 - 1
backend/logic/db/index.js

@@ -14,7 +14,7 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	report: 6,
 	song: 9,
 	station: 8,
-	user: 3,
+	user: 4,
 	youtubeApiRequest: 1,
 	youtubeVideo: 1,
 	ratings: 1,

+ 1 - 1
backend/logic/db/schemas/playlist.js

@@ -18,6 +18,6 @@ export default {
 	createdAt: { type: Date, default: Date.now, required: true },
 	createdFor: { type: String },
 	privacy: { type: String, enum: ["public", "private"], default: "private" },
-	type: { type: String, enum: ["user", "user-liked", "user-disliked", "genre", "station"], required: true },
+	type: { type: String, enum: ["user", "user-liked", "user-disliked", "genre", "station", "admin"], required: true },
 	documentVersion: { type: Number, default: 6, required: true }
 };

+ 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 }
 };

+ 2 - 2
backend/logic/db/schemas/user.js

@@ -2,7 +2,7 @@ import mongoose from "mongoose";
 
 export default {
 	username: { type: String, required: true },
-	role: { type: String, default: "default", required: true },
+	role: { type: String, default: "user", enum: ["user", "moderator", "admin"], required: true },
 	email: {
 		verified: { type: Boolean, default: false, required: true },
 		verificationToken: String,
@@ -48,5 +48,5 @@ export default {
 		anonymousSongRequests: { type: Boolean, default: false, required: true },
 		activityWatch: { type: Boolean, default: false, required: true }
 	},
-	documentVersion: { type: Number, default: 3, required: true }
+	documentVersion: { type: Number, default: 4, required: true }
 };

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

@@ -3,6 +3,7 @@ export default {
 	title: { type: String, trim: true, required: true },
 	author: { type: String, trim: true, required: true },
 	duration: { type: Number, required: true },
+	uploadedAt: { type: Date },
 	createdAt: { type: Date, default: Date.now, required: true },
 	documentVersion: { type: Number, default: 1, required: true }
 };

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

@@ -0,0 +1,286 @@
+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.djs.add": true,
+	"stations.djs.remove": true,
+	"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.users": true,
+	"admin.view.youtubeVideos": true,
+	"apis.searchDiscogs": true,
+	"news.create": true,
+	"news.update": true,
+	"playlists.create.admin": true,
+	"playlists.get": true,
+	"playlists.update.displayName": true,
+	"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,
+	"users.get": true,
+	"users.ban": true,
+	"users.requestPasswordReset": true,
+	"users.resendVerifyEmail": true,
+	"users.update": true,
+	"youtube.requestSetAdmin": true
+};
+permissions.admin = {
+	...permissions.moderator,
+	"admin.view.dataRequests": true,
+	"admin.view.statistics": 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.remove": true,
+	"users.remove.sessions": true,
+	"users.update.restricted": 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(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.");
+						})
+						.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 cb = args[args.length - 1];
+
+		async.waterfall(
+			[
+				next => {
+					if (!session || !session.sessionId) return next("Login required.");
+					return hasPermission(permission, session)
+						.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(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.");
+						})
+						.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;

+ 55 - 0
backend/logic/migration/migrations/migration23.js

@@ -0,0 +1,55 @@
+import async from "async";
+
+/**
+ * Migration 23
+ *
+ * Migration for renaming default user role from default to user
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 23. Finding users with document version 3.`);
+					userModel.find({ documentVersion: 3 }, (err, users) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								users.map(user => user._doc),
+								1,
+								(user, next) => {
+									userModel.updateOne(
+										{ _id: user._id },
+										{
+											$set: {
+												role: user.role === "default" ? "user" : user.role,
+												documentVersion: 4
+											}
+										},
+										next
+									);
+								},
+								err => {
+									this.log("INFO", `Migration 23. Users found: ${users.length}.`);
+									next(err);
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 2 - 0
backend/logic/playlists.js

@@ -1152,6 +1152,7 @@ class _PlaylistsModule extends CoreClass {
 	 * @param {string} payload.includeStation - include station playlists
 	 * @param {string} payload.includeUser - include user playlists
 	 * @param {string} payload.includeGenre - include genre playlists
+	 * @param {string} payload.includeAdmin - include admin playlists
 	 * @param {string} payload.includeOwn - include own user playlists
 	 * @param {string} payload.userId - the user id of the person requesting
 	 * @param {string} payload.includeSongs - include songs
@@ -1167,6 +1168,7 @@ class _PlaylistsModule extends CoreClass {
 						if (payload.includeStation) types.push("station");
 						if (payload.includeUser) types.push("user");
 						if (payload.includeGenre) types.push("genre");
+						if (payload.includeAdmin) types.push("admin");
 						if (types.length === 0 && !payload.includeOwn) return next("No types have been included.");
 
 						const privacies = ["public"];

+ 192 - 102
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;
@@ -77,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 = { data: { user: { _id, name, username, avatar }, stationId } };
+						WSModule.runJob("EMIT_TO_ROOMS", {
+							rooms: [`station.${stationId}`, "home"],
+							args: ["event:station.djs.added", data]
+						});
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: `manage-station.${stationId}`,
+							args: ["event:manageStation.djs.added", data]
+						});
+					}
+				});
+			}
+		});
+
+		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 = { data: { user: { _id, name, username, avatar }, stationId } };
+						WSModule.runJob("EMIT_TO_ROOMS", {
+							rooms: [`station.${stationId}`, "home"],
+							args: ["event:station.djs.removed", data]
+						});
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: `manage-station.${stationId}`,
+							args: ["event:manageStation.djs.removed", data]
+						});
+					}
+				});
+			}
+		});
+
 		const stationModel = (this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }));
 		const stationSchema = (this.stationSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "station" }));
 
@@ -727,9 +771,9 @@ class _StationsModule extends CoreClass {
 	 * @param {string} payload.stationId - the id of the station to process
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
-	PROCESS_VOTE_SKIPS(payload) {
+	PROCESS_SKIP_VOTES(payload) {
 		return new Promise((resolve, reject) => {
-			StationsModule.log("INFO", `Processing vote skips for station ${payload.stationId}.`);
+			StationsModule.log("INFO", `Processing skip votes for station ${payload.stationId}.`);
 
 			async.waterfall(
 				[
@@ -1009,31 +1053,21 @@ 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
-																}
-															});
-													}
-												});
+										const dispatchNextSong = () => {
+											socket.dispatch("event:station.nextSong", {
+												data: {
+													stationId: station._id,
+													currentSong
+												}
+											});
+										};
+										hasPermission("stations.index", session, station._id)
+											.then(() => dispatchNextSong())
+											.catch(() => {
+												hasPermission("stations.index.other", session)
+													.then(() => dispatchNextSong())
+													.catch(() => {});
 											});
-										}
 									}
 								);
 							}
@@ -1083,17 +1117,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 => {
@@ -1190,71 +1216,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);
@@ -1994,6 +1968,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

+ 2 - 1
backend/logic/youtube.js

@@ -1434,7 +1434,8 @@ class _YouTubeModule extends CoreClass {
 									title: data.items[0].snippet.title,
 									author: data.items[0].snippet.channelTitle,
 									thumbnail: data.items[0].snippet.thumbnails.default.url,
-									duration
+									duration,
+									uploadedAt: new Date(data.items[0].snippet.publishedAt)
 								};
 
 								return next(null, false, youtubeVideo);

Plik diff jest za duży
+ 248 - 242
backend/package-lock.json


+ 17 - 17
backend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "musare-backend",
   "private": true,
-  "version": "3.7.1",
+  "version": "3.8.0",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
@@ -17,38 +17,38 @@
   },
   "dependencies": {
     "async": "^3.2.4",
-    "axios": "^0.27.2",
-    "bcrypt": "^5.0.1",
+    "axios": "^1.1.2",
+    "bcrypt": "^5.1.0",
     "bluebird": "^3.7.2",
-    "body-parser": "^1.20.0",
-    "config": "^3.3.7",
+    "body-parser": "^1.20.1",
+    "config": "^3.3.8",
     "cookie-parser": "^1.4.6",
     "cors": "^2.8.5",
-    "express": "^4.18.1",
+    "express": "^4.18.2",
     "moment": "^2.29.4",
-    "mongoose": "^6.5.3",
-    "nodemailer": "^6.7.8",
+    "mongoose": "^6.6.5",
+    "nodemailer": "^6.8.0",
     "oauth": "^0.10.0",
-    "redis": "^4.3.0",
+    "redis": "^4.3.1",
     "retry-axios": "^3.0.0",
     "sha256": "^0.2.0",
-    "socks": "^2.7.0",
-    "underscore": "^1.13.4",
-    "ws": "^8.8.1"
+    "socks": "^2.7.1",
+    "underscore": "^1.13.6",
+    "ws": "^8.9.0"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^5.35.1",
-    "@typescript-eslint/parser": "^5.35.1",
-    "eslint": "^8.22.0",
+    "@typescript-eslint/eslint-plugin": "^5.40.0",
+    "@typescript-eslint/parser": "^5.40.0",
+    "eslint": "^8.25.0",
     "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-jsdoc": "^39.3.6",
     "eslint-plugin-prettier": "^4.2.1",
-    "nodemon": "^2.0.19",
+    "nodemon": "^2.0.20",
     "prettier": "2.7.1",
     "trace-unhandled": "^2.0.1",
     "ts-node": "^10.9.1",
-    "typescript": "^4.8.2"
+    "typescript": "^4.8.4"
   }
 }

+ 3 - 1
docker-compose.yml

@@ -9,6 +9,7 @@ services:
     volumes:
       - ./.git:/opt/app/.parent_git:ro
       - ./backend/config:/opt/app/config
+      - ./types:/opt/types
     environment:
       - CONTAINER_MODE=${CONTAINER_MODE:-prod}
     links:
@@ -30,6 +31,7 @@ services:
     volumes:
       - ./.git:/opt/app/.parent_git:ro
       - ./frontend/dist/config:/opt/app/dist/config
+      - ./types:/opt/types
     environment:
       - FRONTEND_MODE=${FRONTEND_MODE:-prod}
       - CONTAINER_MODE=${CONTAINER_MODE:-prod}
@@ -52,7 +54,7 @@ services:
       - ${MONGO_DATA_LOCATION:-./db}:/data/db
 
   redis:
-    image: docker.io/redis:6.2
+    image: docker.io/redis:7
     restart: ${RESTART_POLICY:-unless-stopped}
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
     volumes:

+ 6 - 0
frontend/.eslintrc

@@ -43,6 +43,12 @@
 		"import/no-unresolved": 0,
 		"import/extensions": 0,
 		"import/prefer-default-export": 0,
+		"import/no-extraneous-dependencies": [
+			"error",
+			{
+				"devDependencies": true
+			}
+		],
 		"prettier/prettier": [
 			"error"
 		],

+ 5 - 5
frontend/Dockerfile

@@ -1,4 +1,4 @@
-FROM node:16.15 AS frontend_node_modules
+FROM node:18 AS frontend_node_modules
 
 RUN mkdir -p /opt/app
 WORKDIR /opt/app
@@ -8,17 +8,17 @@ COPY package-lock.json /opt/app/package-lock.json
 
 RUN npm install --silent
 
-FROM node:16.15 AS musare_frontend
+FROM node:18 AS musare_frontend
 
 ARG FRONTEND_MODE=prod
 ENV FRONTEND_MODE=${FRONTEND_MODE}
 ENV SUPPRESS_NO_CONFIG_WARNING=1
 ENV NODE_CONFIG_DIR=./dist/config
 
-RUN apt-get update
-RUN apt-get install nginx -y
+RUN apt update
+RUN apt install nginx -y
 
-RUN mkdir -p /opt/app
+RUN mkdir -p /opt/app /opt/types
 WORKDIR /opt/app
 
 COPY . /opt/app

+ 1 - 9
frontend/dist/config/template.json

@@ -34,15 +34,7 @@
 	"messages": {
 		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."
 	},
-	// "shortcutOverrides": {
-	// 	"editSong.useAllDiscogs": {
-	// 		"keyCode": 68,
-	// 		"ctrl": true,
-	// 		"alt": true,
-	// 		"shift": false,
-	// 		"preventDefault": true
-	// 	}
-	// },
+	"shortcutOverrides": {},
 	"debug": {
 		"git": {
 			"remote": false,

Plik diff jest za duży
+ 557 - 182
frontend/package-lock.json


+ 31 - 19
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.7.1",
+  "version": "3.8.0",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "author": "Musare Team",
@@ -15,43 +15,55 @@
     "lint": "eslint --cache src --ext .js,.ts,.vue",
     "dev": "vite",
     "prod": "vite build --emptyOutDir",
-    "typescript": "vue-tsc --noEmit --skipLibCheck"
+    "typescript": "vue-tsc --noEmit --skipLibCheck",
+    "test": "vitest",
+    "coverage": "vitest run --coverage"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^5.35.1",
-    "@typescript-eslint/parser": "^5.35.1",
-    "eslint": "^8.22.0",
+    "@pinia/testing": "^0.0.14",
+    "@types/can-autoplay": "^3.0.1",
+    "@types/dompurify": "^2.3.4",
+    "@types/marked": "^4.0.7",
+    "@typescript-eslint/eslint-plugin": "^5.40.0",
+    "@typescript-eslint/parser": "^5.40.0",
+    "@vitest/coverage-c8": "^0.24.1",
+    "@vue/test-utils": "^2.1.0",
+    "eslint": "^8.25.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-prettier": "^4.2.1",
-    "eslint-plugin-vue": "^9.4.0",
+    "eslint-plugin-vue": "^9.6.0",
+    "jsdom": "^20.0.1",
     "less": "^4.1.3",
     "prettier": "^2.7.1",
     "vite-plugin-dynamic-import": "^1.1.1",
-    "vue-eslint-parser": "^9.0.3",
-    "vue-tsc": "^0.39.5"
+    "vitest": "^0.24.1",
+    "vue-eslint-parser": "^9.1.0",
+    "vue-tsc": "^1.0.6"
   },
   "dependencies": {
-    "@vitejs/plugin-vue": "^3.0.3",
+    "@intlify/vite-plugin-vue-i18n": "^6.0.3",
+    "@vitejs/plugin-vue": "^3.1.2",
     "can-autoplay": "^3.0.2",
     "chart.js": "^3.9.1",
-    "config": "^3.3.7",
-    "date-fns": "^2.29.2",
+    "config": "^3.3.8",
+    "date-fns": "^2.29.3",
     "dompurify": "^2.4.0",
     "eslint-config-airbnb-base": "^15.0.0",
     "lofig": "^1.3.4",
-    "marked": "^4.0.19",
+    "marked": "^4.1.1",
     "normalize.css": "^8.0.1",
-    "pinia": "^2.0.21",
+    "pinia": "^2.0.23",
     "toasters": "^2.3.1",
-    "typescript": "^4.8.2",
-    "vite": "^3.0.9",
-    "vue": "^3.2.36",
-    "vue-chartjs": "^4.1.1",
+    "typescript": "^4.8.4",
+    "vite": "^3.1.7",
+    "vue": "^3.2.40",
+    "vue-chartjs": "^4.1.2",
     "vue-content-loader": "^2.0.1",
     "vue-draggable-list": "^0.1.1",
-    "vue-json-pretty": "^2.2.0",
+    "vue-i18n": "^9.2.2",
+    "vue-json-pretty": "^2.2.2",
     "vue-router": "^4.1.5",
-    "vue-tippy": "^6.0.0-alpha.63"
+    "vue-tippy": "^6.0.0-alpha.65"
   }
 }

+ 95 - 112
frontend/src/App.vue

@@ -1,13 +1,15 @@
 <script setup lang="ts">
-import { useRoute, useRouter } from "vue-router";
-import { defineAsyncComponent, ref, computed, watch, onMounted } from "vue";
+import { useRouter } from "vue-router";
+import { defineAsyncComponent, ref, watch, onMounted } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { GenericResponse } from "@musare_types/actions/GenericActions";
+import { GetPreferencesResponse } from "@musare_types/actions/UsersActions";
+import { NewestResponse } from "@musare_types/actions/NewsActions";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useUserPreferencesStore } from "@/stores/userPreferences";
 import { useModalsStore } from "@/stores/modals";
-import ws from "@/ws";
 import aw from "@/aw";
 import keyboardShortcuts from "@/keyboardShortcuts";
 
@@ -22,7 +24,6 @@ const FallingSnow = defineAsyncComponent(
 	() => import("@/components/FallingSnow.vue")
 );
 
-const route = useRoute();
 const router = useRouter();
 
 const { socket } = useWebsocketsStore();
@@ -33,9 +34,10 @@ const modalsStore = useModalsStore();
 const apiDomain = ref("");
 const socketConnected = ref(true);
 const keyIsDown = ref("");
-const scrollPosition = ref({ y: 0, x: 0 });
-const aModalIsOpen2 = ref(false);
-const broadcastChannel = ref();
+const broadcastChannel = ref({
+	user_login: null,
+	nightmode: null
+});
 const christmas = ref(false);
 const disconnectedMessage = ref();
 
@@ -48,25 +50,21 @@ const {
 	changeAnonymousSongRequests,
 	changeActivityWatch
 } = userPreferencesStore;
-const { modals, activeModals } = storeToRefs(modalsStore);
+const { activeModals } = storeToRefs(modalsStore);
 const { openModal, closeCurrentModal } = modalsStore;
 
-const aModalIsOpen = computed(() => Object.keys(activeModals.value).length > 0);
-
 const toggleNightMode = () => {
-	localStorage.setItem("nightmode", `${!nightmode.value}`);
-
 	if (loggedIn.value) {
 		socket.dispatch(
 			"users.updatePreferences",
 			{ nightmode: !nightmode.value },
-			res => {
+			(res: GenericResponse) => {
 				if (res.status !== "success") new Toast(res.message);
 			}
 		);
+	} else {
+		broadcastChannel.value.nightmode.postMessage(!nightmode.value);
 	}
-
-	changeNightmode(!nightmode.value);
 };
 
 const enableNightmode = () => {
@@ -96,41 +94,36 @@ watch(activityWatch, enabled => {
 	if (enabled) aw.enable();
 	else aw.disable();
 });
-watch(aModalIsOpen, isOpen => {
-	if (isOpen) {
-		scrollPosition.value = {
-			x: window.scrollX,
-			y: window.scrollY
-		};
-		aModalIsOpen2.value = true;
-	} else {
-		aModalIsOpen2.value = false;
-		setTimeout(() => {
-			window.scrollTo(scrollPosition.value.x, scrollPosition.value.y);
-		}, 10);
-	}
-});
 
 onMounted(async () => {
 	window
 		.matchMedia("(prefers-color-scheme: dark)")
 		.addEventListener("change", e => {
-			if (e.matches === !nightmode.value) toggleNightMode();
+			if (e.matches === !nightmode.value) changeNightmode(true);
 		});
 
 	if (!loggedIn.value) {
 		lofig.get("cookie.SIDname").then(sid => {
-			broadcastChannel.value = new BroadcastChannel(`${sid}.user_login`);
-			broadcastChannel.value.onmessage = data => {
-				if (data) {
-					broadcastChannel.value.close();
+			broadcastChannel.value.user_login = new BroadcastChannel(
+				`${sid}.user_login`
+			);
+			broadcastChannel.value.user_login.onmessage = res => {
+				if (res.data) {
+					broadcastChannel.value.user_login.close();
 					window.location.reload();
 				}
 			};
+
+			broadcastChannel.value.nightmode = new BroadcastChannel(
+				`${sid}.nightmode`
+			);
+			broadcastChannel.value.nightmode.onmessage = res => {
+				changeNightmode(!!res.data);
+			};
 		});
 	}
 
-	document.onkeydown = (ev: any) => {
+	document.onkeydown = (ev: KeyboardEvent) => {
 		const event = ev || window.event;
 		const { keyCode } = event;
 		const shift = event.shiftKey;
@@ -162,12 +155,7 @@ onMounted(async () => {
 		shift: false,
 		ctrl: false,
 		handler: () => {
-			if (
-				Object.keys(activeModals.value).length !== 0 &&
-				modals.value[
-					activeModals.value[activeModals.value.length - 1]
-				] !== "editSong"
-			)
+			if (Object.keys(activeModals.value).length !== 0)
 				closeCurrentModal();
 		}
 	});
@@ -180,105 +168,100 @@ onMounted(async () => {
 
 	disconnectedMessage.value.hide();
 
-	ws.onConnect(() => {
+	socket.onConnect(() => {
 		socketConnected.value = true;
 
-		socket.dispatch("users.getPreferences", res => {
-			if (res.status === "success") {
-				const { preferences } = res.data;
-
-				changeAutoSkipDisliked(preferences.autoSkipDisliked);
-				changeNightmode(preferences.nightmode);
-				changeActivityLogPublic(preferences.activityLogPublic);
-				changeAnonymousSongRequests(preferences.anonymousSongRequests);
-				changeActivityWatch(preferences.activityWatch);
-
-				if (nightmode.value) enableNightmode();
-				else disableNightmode();
+		socket.dispatch(
+			"users.getPreferences",
+			(res: GetPreferencesResponse) => {
+				if (res.status === "success") {
+					const { preferences } = res.data;
+
+					changeAutoSkipDisliked(preferences.autoSkipDisliked);
+					changeNightmode(preferences.nightmode);
+					changeActivityLogPublic(preferences.activityLogPublic);
+					changeAnonymousSongRequests(
+						preferences.anonymousSongRequests
+					);
+					changeActivityWatch(preferences.activityWatch);
+				}
 			}
-		});
-
-		socket.on("keep.event:user.session.deleted", () =>
-			window.location.reload()
 		);
 
 		const newUser = !localStorage.getItem("firstVisited");
-		socket.dispatch("news.newest", newUser, res => {
+		socket.dispatch("news.newest", newUser, (res: NewestResponse) => {
 			if (res.status !== "success") return;
 
 			const { news } = res.data;
 
 			if (news) {
 				if (newUser) {
-					openModal({ modal: "whatIsNew", data: { news } });
+					openModal({ modal: "whatIsNew", props: { news } });
 				} else if (localStorage.getItem("whatIsNew")) {
 					if (
-						parseInt(localStorage.getItem("whatIsNew")) <
+						parseInt(localStorage.getItem("whatIsNew") as string) <
 						news.createdAt
 					) {
 						openModal({
 							modal: "whatIsNew",
-							data: { news }
+							props: { news }
 						});
-						localStorage.setItem("whatIsNew", news.createdAt);
+						localStorage.setItem(
+							"whatIsNew",
+							news.createdAt.toString()
+						);
 					}
 				} else {
 					if (
-						parseInt(localStorage.getItem("firstVisited")) <
-						news.createdAt
+						localStorage.getItem("firstVisited") &&
+						parseInt(
+							localStorage.getItem("firstVisited") as string
+						) < news.createdAt
 					)
 						openModal({
 							modal: "whatIsNew",
-							data: { news }
+							props: { news }
 						});
-					localStorage.setItem("whatIsNew", news.createdAt);
+					localStorage.setItem(
+						"whatIsNew",
+						news.createdAt.toString()
+					);
 				}
 			}
 
 			if (!localStorage.getItem("firstVisited"))
 				localStorage.setItem("firstVisited", Date.now().toString());
 		});
-	});
+	}, true);
 
-	ws.onDisconnect(true, () => {
+	socket.onDisconnect(() => {
 		socketConnected.value = false;
-	});
+	}, true);
+
+	socket.on("keep.event:user.session.deleted", () =>
+		window.location.reload()
+	);
 
 	apiDomain.value = await lofig.get("backend.apiDomain");
 
 	router.isReady().then(() => {
-		if (route.query.err) {
-			let { err } = route.query;
-			err = JSON.stringify(err)
-				.replace(/</g, "&lt;")
-				.replace(/>/g, "&gt;");
-			router.push({ query: {} });
-			new Toast({ content: err, timeout: 20000 });
-		}
-
-		if (route.query.msg) {
-			let { msg } = route.query;
-			msg = JSON.stringify(msg)
-				.replace(/</g, "&lt;")
-				.replace(/>/g, "&gt;");
-			router.push({ query: {} });
-			new Toast({ content: msg, timeout: 20000 });
-		}
-
-		lofig.get("siteSettings.githubAuthentication").then(enabled => {
-			if (enabled && localStorage.getItem("github_redirect")) {
-				router.push(localStorage.getItem("github_redirect"));
-				localStorage.removeItem("github_redirect");
-			}
-		});
+		lofig
+			.get("siteSettings.githubAuthentication")
+			.then((enabled: boolean) => {
+				if (enabled && localStorage.getItem("github_redirect")) {
+					router.push(
+						localStorage.getItem("github_redirect") as string
+					);
+					localStorage.removeItem("github_redirect");
+				}
+			});
 	});
 
 	if (localStorage.getItem("nightmode") === "true") {
 		changeNightmode(true);
-		enableNightmode();
-	}
+	} else changeNightmode(false);
 
-	lofig.get("siteSettings.christmas").then(enabled => {
+	lofig.get("siteSettings.christmas").then((enabled: boolean) => {
 		if (enabled) {
 			christmas.value = true;
 			enableChristmasMode();
@@ -291,11 +274,7 @@ onMounted(async () => {
 	<div class="upper-container">
 		<banned-page v-if="banned" />
 		<div v-else class="upper-container">
-			<router-view
-				:key="$route.fullPath"
-				class="main-container"
-				:class="{ 'main-container-modal-active': aModalIsOpen2 }"
-			/>
+			<router-view :key="$route.fullPath" class="main-container" />
 		</div>
 		<falling-snow v-if="christmas" />
 		<modal-manager />
@@ -565,20 +544,20 @@ code {
 }
 
 html {
-	overflow: auto !important;
 	height: 100%;
-	background-color: inherit;
+	overflow: hidden;
 	font-size: 14px;
 }
 
 body {
 	background-color: var(--light-grey);
 	color: var(--dark-grey);
-	height: 100%;
 	line-height: 1.4285714;
 	font-size: 1rem;
 	font-family: "Inter", Helvetica, Arial, sans-serif;
+	height: 100%;
 	max-width: 100%;
+	overflow-y: auto !important;
 	overflow-x: hidden;
 }
 
@@ -770,11 +749,6 @@ textarea {
 	flex-direction: column;
 	max-width: 100%;
 
-	&.main-container-modal-active {
-		height: 100% !important;
-		overflow: hidden !important;
-	}
-
 	> .container {
 		position: relative;
 		flex: 1 0 auto;
@@ -1518,7 +1492,7 @@ button.delete:focus {
 			&.label {
 				border-radius: 0;
 			}
-			&:first-child {
+			&:first-child:not(:only-child) {
 				& > input,
 				& > select,
 				& > .button,
@@ -1526,7 +1500,7 @@ button.delete:focus {
 					border-radius: @border-radius 0 0 @border-radius;
 				}
 			}
-			&:last-child {
+			&:last-child:not(:only-child) {
 				& > input,
 				& > select,
 				& > .button,
@@ -1534,6 +1508,14 @@ button.delete:focus {
 					border-radius: 0 @border-radius @border-radius 0;
 				}
 			}
+			&:only-child {
+				& > input,
+				& > select,
+				& > .button,
+				&.label {
+					border-radius: @border-radius;
+				}
+			}
 		}
 	}
 
@@ -1728,6 +1710,7 @@ h4.section-title {
 		}
 
 		.stop-icon,
+		.remove-from-playlist-icon,
 		.delete-icon {
 			color: var(--dark-red);
 		}

+ 0 - 47
frontend/src/auth.ts

@@ -1,47 +0,0 @@
-let callbacks = [];
-const bannedCallbacks = [];
-
-export default {
-	ready: false,
-	authenticated: false,
-	username: "",
-	userId: "",
-	role: "default",
-	banned: null,
-	ban: {},
-
-	getStatus(cb) {
-		if (this.ready)
-			cb(this.authenticated, this.role, this.username, this.userId);
-		else callbacks.push(cb);
-	},
-
-	setBanned(ban) {
-		this.banned = true;
-		this.ban = ban;
-		bannedCallbacks.forEach(callback => {
-			callback(true, this.ban);
-		});
-	},
-
-	isBanned(cb) {
-		if (this.ready) return cb(false);
-		if (!this.ready && this.banned === true) return cb(true, this.ban);
-		return bannedCallbacks.push(cb);
-	},
-
-	data(authenticated, role, username, userId) {
-		this.authenticated = authenticated;
-		this.role = role;
-		this.username = username;
-		this.userId = userId;
-		this.ready = true;
-		callbacks.forEach(callback => {
-			callback(authenticated, role, username, userId);
-		});
-		bannedCallbacks.forEach(callback => {
-			callback(false);
-		});
-		callbacks = [];
-	}
-};

+ 2 - 15
frontend/src/aw.ts

@@ -1,4 +1,5 @@
 import Toast from "toasters";
+import utils from "@/utils";
 
 let gotPong = false;
 let pingTries = 0;
@@ -66,21 +67,7 @@ export default {
 
 	enable() {
 		if (!enabled) {
-			uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
-				/[xy]/g,
-				symbol => {
-					let array;
-
-					if (symbol === "y") {
-						array = ["8", "9", "a", "b"];
-						return array[Math.floor(Math.random() * array.length)];
-					}
-
-					array = new Uint8Array(1);
-					window.crypto.getRandomValues(array);
-					return (array[0] % 16).toString(16);
-				}
-			);
+			uuid = utils.guid();
 
 			document.addEventListener(
 				"ActivityWatchMusareEvent",

+ 6 - 5
frontend/src/classes/ListenerHandler.class.ts

@@ -1,10 +1,11 @@
 export default class ListenerHandler extends EventTarget {
-	listeners: {
-		[name: string]: Array<{
+	listeners: Record<
+		string,
+		{
 			cb: (event: any) => void;
-			options: { replaceable: boolean };
-		}>;
-	};
+			options: { replaceable: boolean; modalUuid?: string };
+		}[]
+	>;
 
 	constructor() {
 		super();

+ 240 - 0
frontend/src/classes/SocketHandler.class.ts

@@ -0,0 +1,240 @@
+import ListenerHandler from "@/classes/ListenerHandler.class";
+import { useUserAuthStore } from "@/stores/userAuth";
+import utils from "@/utils";
+
+export default class SocketHandler {
+	socket?: WebSocket;
+
+	url: string;
+
+	dispatcher: ListenerHandler;
+
+	onConnectCbs: {
+		temp: (() => void)[];
+		persist: (() => void)[];
+	};
+
+	ready: boolean;
+
+	firstInit: boolean;
+
+	pendingDispatches: (() => void)[];
+
+	onDisconnectCbs: {
+		temp: (() => void)[];
+		persist: (() => void)[];
+	};
+
+	CB_REFS: Record<string, (...args: any[]) => void>;
+
+	PROGRESS_CB_REFS: Record<string, (...args: any[]) => void>;
+
+	data: {
+		dispatch?: Record<string, (...args: any[]) => any>;
+		progress?: Record<string, (...args: any[]) => any>;
+		on?: Record<string, any>;
+	}; // Mock only
+
+	executeDispatch: boolean; // Mock only
+
+	trigger: (type: string, target: string, data?: any) => void; // Mock only
+
+	constructor(url: string) {
+		this.dispatcher = new ListenerHandler();
+
+		this.url = url;
+
+		this.onConnectCbs = {
+			temp: [],
+			persist: []
+		};
+
+		this.ready = false;
+		this.firstInit = true;
+
+		this.pendingDispatches = [];
+
+		this.onDisconnectCbs = {
+			temp: [],
+			persist: []
+		};
+
+		// references for when a dispatch event is ready to callback from server to client
+		this.CB_REFS = {};
+		this.PROGRESS_CB_REFS = {};
+
+		this.init();
+
+		// Mock only
+		this.data = {};
+		this.executeDispatch = true;
+		this.trigger = () => {};
+	}
+
+	init() {
+		this.socket = new WebSocket(this.url);
+
+		const userAuthStore = useUserAuthStore();
+
+		this.socket.onopen = () => {
+			console.log("WS: SOCKET OPENED");
+		};
+
+		this.socket.onmessage = message => {
+			const data = JSON.parse(message.data);
+			const name = data.shift(0);
+
+			if (name === "CB_REF") {
+				const CB_REF = data.shift(0);
+				this.CB_REFS[CB_REF](...data);
+				return delete this.CB_REFS[CB_REF];
+			}
+			if (name === "PROGRESS_CB_REF") {
+				const PROGRESS_CB_REF = data.shift(0);
+				this.PROGRESS_CB_REFS[PROGRESS_CB_REF](...data);
+			}
+
+			if (name === "ERROR") console.log("WS: SOCKET ERROR:", data[0]);
+
+			return this.dispatcher.dispatchEvent(
+				new CustomEvent(name, {
+					detail: data
+				})
+			);
+		};
+
+		this.socket.onclose = () => {
+			console.log("WS: SOCKET CLOSED");
+
+			this.ready = false;
+			this.firstInit = false;
+
+			this.onDisconnectCbs.temp.forEach(cb => cb());
+			this.onDisconnectCbs.persist.forEach(cb => cb());
+
+			// try to reconnect every 1000ms, if the user isn't banned
+			if (!userAuthStore.banned) setTimeout(() => this.init(), 1000);
+		};
+
+		this.socket.onerror = err => {
+			console.log("WS: SOCKET ERROR", err);
+		};
+
+		if (this.firstInit) {
+			this.firstInit = false;
+			this.on("ready", () => {
+				console.log("WS: SOCKET READY");
+
+				this.onConnectCbs.temp.forEach(cb => cb());
+				this.onConnectCbs.persist.forEach(cb => cb());
+
+				this.ready = true;
+
+				setTimeout(() => {
+					// dispatches that were attempted while the server was offline
+					this.pendingDispatches.forEach(cb => cb());
+					this.pendingDispatches = [];
+				}, 150); // small delay between readyState being 1 and the server actually receiving dispatches
+
+				userAuthStore.updatePermissions();
+			});
+		}
+	}
+
+	on(
+		target: string,
+		cb: (...args: any[]) => any,
+		options?: EventListenerOptions & {
+			replaceable?: boolean;
+			modalUuid?: string;
+		}
+	) {
+		this.dispatcher.addEventListener(
+			target,
+			(event: CustomEvent) => cb(...event.detail),
+			options
+		);
+	}
+
+	dispatch(...args: [string, ...any[]]) {
+		if (!this.socket || this.socket.readyState !== 1) {
+			this.pendingDispatches.push(() => this.dispatch(...args));
+			return undefined;
+		}
+
+		const lastArg = args[args.length - 1];
+		const CB_REF = utils.guid();
+
+		if (typeof lastArg === "function") {
+			this.CB_REFS[CB_REF] = lastArg;
+
+			return this.socket.send(
+				JSON.stringify([...args.slice(0, -1), { CB_REF }])
+			);
+		}
+		if (typeof lastArg === "object") {
+			this.CB_REFS[CB_REF] = lastArg.cb;
+			this.PROGRESS_CB_REFS[CB_REF] = lastArg.onProgress;
+
+			return this.socket.send(
+				JSON.stringify([
+					...args.slice(0, -1),
+					{ CB_REF, onProgress: true }
+				])
+			);
+		}
+
+		return this.socket.send(JSON.stringify([...args]));
+	}
+
+	onConnect(cb: (...args: any[]) => any, persist = false) {
+		if (this.socket && this.socket.readyState === 1 && this.ready) cb();
+
+		if (persist) this.onConnectCbs.persist.push(cb);
+		else this.onConnectCbs.temp.push(cb);
+	}
+
+	onDisconnect(cb: (...args: any[]) => any, persist = false) {
+		if (persist) this.onDisconnectCbs.persist.push(cb);
+		else this.onDisconnectCbs.temp.push(cb);
+	}
+
+	clearCallbacks() {
+		this.onConnectCbs.temp = [];
+		this.onDisconnectCbs.temp = [];
+	}
+
+	destroyListeners() {
+		Object.keys(this.CB_REFS).forEach(id => {
+			if (
+				id.indexOf("$event:") !== -1 &&
+				id.indexOf("$event:keep.") === -1
+			)
+				delete this.CB_REFS[id];
+		});
+
+		Object.keys(this.PROGRESS_CB_REFS).forEach(id => {
+			if (
+				id.indexOf("$event:") !== -1 &&
+				id.indexOf("$event:keep.") === -1
+			)
+				delete this.PROGRESS_CB_REFS[id];
+		});
+
+		// destroy all listeners that aren't site-wide
+		Object.keys(this.dispatcher.listeners).forEach(type => {
+			if (type.indexOf("keep.") === -1 && type !== "ready")
+				delete this.dispatcher.listeners[type];
+		});
+	}
+
+	destroyModalListeners(modalUuid: string) {
+		// destroy all listeners for a specific modal
+		Object.keys(this.dispatcher.listeners).forEach(type =>
+			this.dispatcher.listeners[type].forEach((element, index) => {
+				if (element.options && element.options.modalUuid === modalUuid)
+					this.dispatcher.listeners[type].splice(index, 1);
+			})
+		);
+	}
+}

+ 139 - 0
frontend/src/classes/__mocks__/SocketHandler.class.ts

@@ -0,0 +1,139 @@
+import ListenerHandler from "@/classes/ListenerHandler.class";
+
+export default class SocketHandlerMock {
+	dispatcher: ListenerHandler;
+
+	url: string;
+
+	data: {
+		dispatch?: Record<string, (...args: any[]) => any>;
+		progress?: Record<string, (...args: any[]) => any>;
+		on?: Record<string, any>;
+	};
+
+	onDisconnectCbs: {
+		temp: any[];
+		persist: any[];
+	};
+
+	executeDispatch: boolean;
+
+	constructor(url: string) {
+		this.dispatcher = new ListenerHandler();
+		this.url = url;
+		this.data = {
+			dispatch: {},
+			progress: {},
+			on: {}
+		};
+		this.onDisconnectCbs = {
+			temp: [],
+			persist: []
+		};
+		this.executeDispatch = true;
+	}
+
+	on(
+		target: string,
+		cb: (...args: any[]) => any,
+		options?: EventListenerOptions
+	) {
+		const onData = this.data.on && this.data.on[target];
+		this.dispatcher.addEventListener(
+			`on.${target}`,
+			(event: CustomEvent) => cb(event.detail() || onData),
+			options
+		);
+	}
+
+	dispatch(target: string, ...args: any[]) {
+		const lastArg = args[args.length - 1];
+		const _args = args.slice(0, -1);
+		const dispatchData = () =>
+			this.data.dispatch &&
+			typeof this.data.dispatch[target] === "function"
+				? this.data.dispatch[target](..._args)
+				: undefined;
+		const progressData = () =>
+			this.data.progress &&
+			typeof this.data.progress[target] === "function"
+				? this.data.progress[target](..._args)
+				: undefined;
+
+		if (typeof lastArg === "function") {
+			if (this.executeDispatch && dispatchData()) lastArg(dispatchData());
+			else if (!this.executeDispatch)
+				this.dispatcher.addEventListener(
+					`dispatch.${target}`,
+					(event: CustomEvent) =>
+						lastArg(event.detail(..._args) || dispatchData()),
+					false
+				);
+		} else if (typeof lastArg === "object") {
+			if (this.executeDispatch) {
+				if (progressData())
+					progressData().forEach((data: any) => {
+						lastArg.onProgress(data);
+					});
+				if (dispatchData()) lastArg.cb(dispatchData());
+			} else {
+				this.dispatcher.addEventListener(
+					`progress.${target}`,
+					(event: CustomEvent) => {
+						if (event.detail(..._args))
+							lastArg.onProgress(event.detail(..._args));
+						else if (progressData())
+							progressData().forEach((data: any) => {
+								lastArg.onProgress(data);
+							});
+					},
+					false
+				);
+				this.dispatcher.addEventListener(
+					`dispatch.${target}`,
+					(event: CustomEvent) =>
+						lastArg.cb(event.detail(..._args) || dispatchData()),
+					false
+				);
+			}
+		}
+	}
+
+	// eslint-disable-next-line class-methods-use-this
+	onConnect(cb: (...args: any[]) => any) {
+		cb();
+	}
+
+	onDisconnect(cb: (...args: any[]) => any, persist = false) {
+		if (persist) this.onDisconnectCbs.persist.push(cb);
+		else this.onDisconnectCbs.temp.push(cb);
+
+		this.dispatcher.addEventListener(
+			"socket.disconnect",
+			() => {
+				this.onDisconnectCbs.temp.forEach(callback => callback());
+				this.onDisconnectCbs.persist.forEach(callback => callback());
+			},
+			false
+		);
+	}
+
+	clearCallbacks() {
+		this.onDisconnectCbs.temp = [];
+	}
+
+	// eslint-disable-next-line class-methods-use-this
+	destroyModalListeners() {}
+
+	trigger(type: string, target: string, data?: any) {
+		this.dispatcher.dispatchEvent(
+			new CustomEvent(`${type}.${target}`, {
+				detail: (...args: any[]) => {
+					if (typeof data === "function") return data(...args);
+					if (typeof data === "undefined") return undefined;
+					return JSON.parse(JSON.stringify(data));
+				}
+			})
+		);
+	}
+}

+ 2 - 2
frontend/src/components/ActivityItem.vue

@@ -126,7 +126,7 @@ onMounted(() => {
 						@click="
 							openModal({
 								modal: 'viewReport',
-								data: { reportId: activity.payload.reportId }
+								props: { reportId: activity.payload.reportId }
 							})
 						"
 						>report</a
@@ -139,7 +139,7 @@ onMounted(() => {
 						@click="
 							openModal({
 								modal: 'editPlaylist',
-								data: {
+								props: {
 									playlistId: activity.payload.playlistId
 								}
 							})

+ 49 - 12
frontend/src/components/AddToPlaylistDropdown.vue

@@ -2,10 +2,14 @@
 import { ref, onMounted } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import {
+	AddSongToPlaylistResponse,
+	IndexMyPlaylistsResponse,
+	RemoveSongFromPlaylistResponse
+} from "@musare_types/actions/PlaylistsActions";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserPlaylistsStore } from "@/stores/userPlaylists";
 import { useModalsStore } from "@/stores/modals";
-import ws from "@/ws";
 
 const props = defineProps({
 	song: {
@@ -25,18 +29,11 @@ const dropdown = ref(null);
 const { socket } = useWebsocketsStore();
 const userPlaylistsStore = useUserPlaylistsStore();
 
-const { playlists, fetchedPlaylists } = storeToRefs(userPlaylistsStore);
+const { playlists } = storeToRefs(userPlaylistsStore);
 const { setPlaylists, addPlaylist, removePlaylist } = userPlaylistsStore;
 
 const { openModal } = useModalsStore();
 
-const init = () => {
-	if (!fetchedPlaylists.value)
-		socket.dispatch("playlists.indexMyPlaylists", res => {
-			if (res.status === "success")
-				if (!fetchedPlaylists.value) setPlaylists(res.data.playlists);
-		});
-};
 const hasSong = playlist =>
 	playlist.songs.map(song => song.youtubeId).indexOf(props.song.youtubeId) !==
 	-1;
@@ -48,14 +45,14 @@ const toggleSongInPlaylist = playlistIndex => {
 			false,
 			props.song.youtubeId,
 			playlist._id,
-			res => new Toast(res.message)
+			(res: AddSongToPlaylistResponse) => new Toast(res.message)
 		);
 	} else {
 		socket.dispatch(
 			"playlists.removeSongFromPlaylist",
 			props.song.youtubeId,
 			playlist._id,
-			res => new Toast(res.message)
+			(res: RemoveSongFromPlaylistResponse) => new Toast(res.message)
 		);
 	}
 };
@@ -71,7 +68,14 @@ const createPlaylist = () => {
 };
 
 onMounted(() => {
-	ws.onConnect(init);
+	socket.onConnect(() => {
+		socket.dispatch(
+			"playlists.indexMyPlaylists",
+			(res: IndexMyPlaylistsResponse) => {
+				if (res.status === "success") setPlaylists(res.data.playlists);
+			}
+		);
+	});
 
 	socket.on("event:playlist.created", res => addPlaylist(res.data.playlist), {
 		replaceable: true
@@ -94,6 +98,39 @@ onMounted(() => {
 		},
 		{ replaceable: true }
 	);
+
+	socket.on(
+		"event:playlist.song.added",
+		res => {
+			playlists.value.forEach((playlist, index) => {
+				if (playlist._id === res.data.playlistId) {
+					playlists.value[index].songs.push(res.data.song);
+				}
+			});
+		},
+		{ replaceable: true }
+	);
+
+	socket.on(
+		"event:playlist.song.removed",
+		res => {
+			playlists.value.forEach((playlist, playlistIndex) => {
+				if (playlist._id === res.data.playlistId) {
+					playlists.value[playlistIndex].songs.forEach(
+						(song, songIndex) => {
+							if (song.youtubeId === res.data.youtubeId) {
+								playlists.value[playlistIndex].songs.splice(
+									songIndex,
+									1
+								);
+							}
+						}
+					);
+				}
+			});
+		},
+		{ replaceable: true }
+	);
 });
 </script>
 

+ 56 - 64
frontend/src/components/AdvancedTable.vue

@@ -17,7 +17,6 @@ import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import keyboardShortcuts from "@/keyboardShortcuts";
-import ws from "@/ws";
 import { useDragBox } from "@/composables/useDragBox";
 import {
 	TableColumn,
@@ -48,7 +47,10 @@ const props = defineProps({
 	width: Width of column, e.g. 100px
 	maxWidth: Maximum width of column, e.g. 150px
 	*/
-	columnDefault: { type: Object as PropType<TableColumn>, default: () => {} },
+	columnDefault: {
+		type: Object as PropType<TableColumn>,
+		default: () => ({})
+	},
 	columns: {
 		type: Array as PropType<TableColumn[]>,
 		default: () => []
@@ -65,7 +67,7 @@ const props = defineProps({
 	events: { type: Object as PropType<TableEvents>, default: () => {} },
 	bulkActions: {
 		type: Object as PropType<TableBulkActions>,
-		default: () => {}
+		default: () => ({})
 	}
 });
 
@@ -670,50 +672,40 @@ const columnOrderChanged = () => {
 };
 
 const getTableSettings = () => {
-	const urlTableSettings = <
-		{
-			page: number;
-			pageSize: number;
-			shownColumns: string[];
-			columnOrder: string[];
-			columnWidths: {
-				name: string;
-				width: number;
-			}[];
-			columnSort: {
-				[name: string]: string;
-			};
-			filter: {
-				appliedFilters: TableFilter[];
-				appliedFilterOperator: string;
-			};
-		}
-	>{};
+	const urlTableSettings: {
+		page: number;
+		pageSize: number;
+		shownColumns: string[];
+		columnOrder: string[];
+		columnWidths: {
+			name: string;
+			width: number;
+		}[];
+		columnSort: Record<string, string>;
+		filter: {
+			appliedFilters: TableFilter[];
+			appliedFilterOperator: string;
+		};
+	} = {};
 	if (props.query) {
 		if (route.query.page)
-			urlTableSettings.page = Number.parseInt(<string>route.query.page);
+			urlTableSettings.page = Number.parseInt(route.query.page);
 		if (route.query.pageSize)
-			urlTableSettings.pageSize = Number.parseInt(
-				<string>route.query.pageSize
-			);
+			urlTableSettings.pageSize = Number.parseInt(route.query.pageSize);
 		if (route.query.shownColumns)
 			urlTableSettings.shownColumns = JSON.parse(
-				<string>route.query.shownColumns
+				route.query.shownColumns
 			);
 		if (route.query.columnOrder)
-			urlTableSettings.columnOrder = JSON.parse(
-				<string>route.query.columnOrder
-			);
+			urlTableSettings.columnOrder = JSON.parse(route.query.columnOrder);
 		if (route.query.columnWidths)
 			urlTableSettings.columnWidths = JSON.parse(
-				<string>route.query.columnWidths
+				route.query.columnWidths
 			);
 		if (route.query.columnSort)
-			urlTableSettings.columnSort = JSON.parse(
-				<string>route.query.columnSort
-			);
+			urlTableSettings.columnSort = JSON.parse(route.query.columnSort);
 		if (route.query.filter)
-			urlTableSettings.filter = JSON.parse(<string>route.query.filter);
+			urlTableSettings.filter = JSON.parse(route.query.filter);
 	}
 
 	const localStorageTableSettings = JSON.parse(
@@ -763,39 +755,14 @@ const removeData = index => {
 	};
 };
 
-const init = () => {
-	getData();
-	if (props.query) setQuery();
-	if (props.events) {
-		// if (props.events.room)
-		// 	socket.dispatch("apis.joinRoom", props.events.room, () => {});
-		if (props.events.adminRoom)
-			socket.dispatch(
-				"apis.joinAdminRoom",
-				props.events.adminRoom,
-				() => {}
-			);
-	}
-	props.filters.forEach(filter => {
-		if (filter.autosuggest && filter.autosuggestDataAction) {
-			socket.dispatch(filter.autosuggestDataAction, res => {
-				if (res.status === "success") {
-					const { items } = res.data;
-					autosuggest.value.allItems[filter.name] = items;
-				} else {
-					new Toast(res.message);
-				}
-			});
-		}
-	});
-};
-
 onMounted(async () => {
 	const tableSettings = getTableSettings();
 
 	const columns = [
 		...props.columns.map(column => ({
-			...props.columnDefault,
+			...(typeof props.columnDefault === "object"
+				? props.columnDefault
+				: {}),
 			...column
 		})),
 		{
@@ -934,7 +901,32 @@ onMounted(async () => {
 		}
 	}
 
-	ws.onConnect(init);
+	socket.onConnect(() => {
+		getData();
+		if (props.query) setQuery();
+		if (props.events) {
+			// if (props.events.room)
+			// 	socket.dispatch("apis.joinRoom", props.events.room, () => {});
+			if (props.events.adminRoom)
+				socket.dispatch(
+					"apis.joinAdminRoom",
+					props.events.adminRoom,
+					() => {}
+				);
+		}
+		props.filters.forEach(filter => {
+			if (filter.autosuggest && filter.autosuggestDataAction) {
+				socket.dispatch(filter.autosuggestDataAction, res => {
+					if (res.status === "success") {
+						const { items } = res.data;
+						autosuggest.value.allItems[filter.name] = items;
+					} else {
+						new Toast(res.message);
+					}
+				});
+			}
+		});
+	});
 
 	// TODO, this doesn't address special properties
 	if (props.events && props.events.updated)

+ 46 - 0
frontend/src/components/ChristmasLights.spec.ts

@@ -0,0 +1,46 @@
+import { flushPromises } from "@vue/test-utils";
+import ChristmasLights from "@/components/ChristmasLights.vue";
+import { getWrapper } from "@/tests/utils/utils";
+import { useUserAuthStore } from "@/stores/userAuth";
+
+describe("ChristmasLights component", () => {
+	beforeEach(async context => {
+		context.wrapper = await getWrapper(ChristmasLights);
+	});
+
+	test("small prop", async ({ wrapper }) => {
+		await wrapper.setProps({
+			small: false
+		});
+		expect(wrapper.classes()).not.toContain("christmas-lights-small");
+
+		await wrapper.setProps({
+			small: true
+		});
+		expect(wrapper.classes()).toContain("christmas-lights-small");
+	});
+
+	test("lights prop", async ({ wrapper }) => {
+		await wrapper.setProps({
+			lights: 10
+		});
+		expect(
+			wrapper.findAll(".christmas-lights .christmas-wire").length
+		).toBe(10 + 1);
+		expect(
+			wrapper.findAll(".christmas-lights .christmas-light").length
+		).toBe(10);
+	});
+
+	test("loggedIn state", async ({ wrapper }) => {
+		const userAuthStore = useUserAuthStore();
+
+		expect(userAuthStore.loggedIn).toEqual(false);
+		expect(wrapper.classes()).not.toContain("loggedIn");
+
+		userAuthStore.loggedIn = true;
+		await flushPromises();
+		expect(userAuthStore.loggedIn).toEqual(true);
+		expect(wrapper.classes()).toContain("loggedIn");
+	});
+});

+ 1 - 1
frontend/src/components/FloatingBox.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { onMounted, onUnmounted, ref, defineExpose, nextTick } from "vue";
+import { onMounted, onUnmounted, ref, nextTick } from "vue";
 import { useDragBox } from "@/composables/useDragBox";
 
 const props = defineProps({

+ 12 - 0
frontend/src/components/InfoIcon.spec.ts

@@ -0,0 +1,12 @@
+import InfoIcon from "@/components/InfoIcon.vue";
+import { getWrapper } from "@/tests/utils/utils";
+
+test("InfoIcon component", async () => {
+	const wrapper = await getWrapper(InfoIcon, {
+		props: { tooltip: "This is a tooltip" }
+	});
+
+	expect(wrapper.attributes("content")).toBe("This is a tooltip");
+
+	// await wrapper.trigger("onmouseover");
+});

+ 39 - 0
frontend/src/components/InputHelpBox.spec.ts

@@ -0,0 +1,39 @@
+import InputHelpBox from "@/components/InputHelpBox.vue";
+import { getWrapper } from "@/tests/utils/utils";
+
+describe("InputHelpBox component", () => {
+	beforeEach(async context => {
+		context.wrapper = await getWrapper(InputHelpBox, {
+			props: {
+				message: "",
+				valid: true
+			}
+		});
+	});
+
+	test("message prop", async ({ wrapper }) => {
+		await wrapper.setProps({
+			message: "This input has not been entered and is valid."
+		});
+		expect(wrapper.text()).toBe(
+			"This input has not been entered and is valid."
+		);
+	});
+
+	describe.each([
+		{ valid: true, entered: true, expected: "is-success" },
+		{ valid: true, entered: false, expected: "is-grey" },
+		{ valid: true, entered: undefined, expected: "is-success" },
+		{ valid: false, entered: true, expected: "is-danger" },
+		{ valid: false, entered: false, expected: "is-grey" },
+		{ valid: false, entered: undefined, expected: "is-danger" }
+	])("valid and entered props %j", ({ valid, entered, expected }) => {
+		test("class updated", async ({ wrapper }) => {
+			await wrapper.setProps({
+				valid,
+				entered
+			});
+			expect(wrapper.classes()).toContain(expected);
+		});
+	});
+});

+ 190 - 0
frontend/src/components/LongJobs.spec.ts

@@ -0,0 +1,190 @@
+import { flushPromises } from "@vue/test-utils";
+import LongJobs from "@/components/LongJobs.vue";
+import FloatingBox from "@/components/FloatingBox.vue";
+import { getWrapper } from "@/tests/utils/utils";
+import { useLongJobsStore } from "@/stores/longJobs";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+describe("LongJobs component", async () => {
+	beforeEach(async context => {
+		context.mockSocket = {
+			data: {
+				dispatch: {
+					"users.getLongJobs": () => ({
+						status: "success",
+						data: {
+							longJobs: [
+								{
+									id: "8704d336-660f-4d23-8c18-a7271c6656b5",
+									name: "Bulk verifying songs",
+									status: "success",
+									message:
+										"50 songs have been successfully verified"
+								}
+							]
+						}
+					}),
+					"users.getLongJob": id =>
+						id === "bf3dc3aa-e7aa-4b69-bfd1-8e979fe7dfa5"
+							? {
+									status: "success",
+									data: {
+										longJob: {
+											id,
+											name: "Bulk editing tags",
+											status: "success",
+											message: "Successfully edited tags."
+										}
+									}
+							  }
+							: {
+									status: "error",
+									message: "Long job not found."
+							  },
+					"users.removeLongJob": () => ({
+						status: "success"
+					})
+				},
+				progress: {
+					"users.getLongJob": id =>
+						id === "bf3dc3aa-e7aa-4b69-bfd1-8e979fe7dfa5"
+							? [
+									{
+										id,
+										name: "Bulk editing tags",
+										status: "started",
+										message: "Updating tags."
+									},
+									{
+										id,
+										name: "Bulk editing tags",
+										status: "update",
+										message: "Updating tags in MongoDB."
+									}
+							  ]
+							: []
+				},
+				on: {
+					"keep.event:longJob.added": {
+						data: { jobId: "bf3dc3aa-e7aa-4b69-bfd1-8e979fe7dfa5" }
+					},
+					"keep.event:longJob.removed": {
+						data: { jobId: "8704d336-660f-4d23-8c18-a7271c6656b5" }
+					}
+				}
+			}
+		};
+	});
+
+	test("component does not render if there are no jobs", async () => {
+		const wrapper = await getWrapper(LongJobs, { mockSocket: true });
+		expect(wrapper.findComponent(FloatingBox).exists()).toBeFalsy();
+	});
+
+	test("component and jobs render if jobs exists", async ({ mockSocket }) => {
+		const wrapper = await getWrapper(LongJobs, {
+			mockSocket,
+			stubs: { FloatingBox },
+			loginRequired: true
+		});
+		expect(wrapper.findComponent(FloatingBox).exists()).toBeTruthy();
+		const activeJobs = wrapper.findAll(".active-jobs .active-job");
+		const { longJobs } =
+			mockSocket.data.dispatch["users.getLongJobs"]().data;
+		expect(activeJobs.length).toBe(longJobs.length);
+	});
+
+	describe.each(["started", "update", "success", "error"])(
+		"job with %s status",
+		status => {
+			const isRemoveable = status === "success" || status === "error";
+
+			beforeEach(async context => {
+				const getLongJobs =
+					context.mockSocket.data.dispatch["users.getLongJobs"]();
+				getLongJobs.data.longJobs[0].status = status;
+				context.mockSocket.data.dispatch["users.getLongJobs"] = () =>
+					getLongJobs;
+
+				context.wrapper = await getWrapper(LongJobs, {
+					mockSocket: context.mockSocket,
+					stubs: { FloatingBox },
+					loginRequired: true
+				});
+			});
+
+			test("status icon, name and message render correctly", ({
+				wrapper,
+				mockSocket
+			}) => {
+				const activeJob = wrapper.find(".active-jobs .active-job");
+				const job =
+					mockSocket.data.dispatch["users.getLongJobs"]().data
+						.longJobs[0];
+				let icon;
+				if (job.status === "success") icon = "Complete";
+				else if (job.status === "error") icon = "Failed";
+				else if (job.status === "started" || job.status === "update")
+					icon = "In Progress";
+				icon = `i[content="${icon}"]`;
+				expect(activeJob.find(icon).exists()).toBeTruthy();
+				expect(activeJob.find(".name").text()).toBe(job.name);
+				(<any>(
+					activeJob.find(".actions .message").element.parentElement
+				))._tippy.show();
+				expect(
+					document.body.querySelector(
+						"body > [id^=tippy] .tippy-box .long-job-message"
+					).textContent
+				).toBe(`Latest Update:${job.message}`);
+			});
+
+			test(`job is ${
+				isRemoveable ? "" : "not "
+			}removed on click of clear icon`, async ({ wrapper }) => {
+				await wrapper
+					.find(".active-job .actions .clear")
+					.trigger("click");
+				await flushPromises();
+				const longJobsStore = useLongJobsStore();
+				expect(longJobsStore.removeJob).toBeCalledTimes(
+					isRemoveable ? 1 : 0
+				);
+				expect(wrapper.findComponent(FloatingBox).exists()).not.toEqual(
+					isRemoveable
+				);
+			});
+		}
+	);
+
+	test("keep.event:longJob.added", async ({ mockSocket }) => {
+		const wrapper = await getWrapper(LongJobs, {
+			mockSocket,
+			stubs: { FloatingBox },
+			loginRequired: true
+		});
+		const websocketsStore = useWebsocketsStore();
+		websocketsStore.socket.trigger("on", "keep.event:longJob.added");
+		await flushPromises();
+		const longJobsStore = useLongJobsStore();
+		expect(longJobsStore.setJob).toBeCalledTimes(3);
+		const activeJobs = wrapper.findAll(".active-jobs .active-job");
+		const { longJobs } =
+			mockSocket.data.dispatch["users.getLongJobs"]().data;
+		expect(activeJobs.length).toBe(longJobs.length + 1);
+	});
+
+	test("keep.event:longJob.removed", async ({ mockSocket }) => {
+		const wrapper = await getWrapper(LongJobs, {
+			mockSocket,
+			stubs: { FloatingBox },
+			loginRequired: true
+		});
+		const websocketsStore = useWebsocketsStore();
+		websocketsStore.socket.trigger("on", "keep.event:longJob.removed");
+		await flushPromises();
+		const longJobsStore = useLongJobsStore();
+		expect(longJobsStore.removeJob).toBeCalledTimes(1);
+		expect(wrapper.findComponent(FloatingBox).exists()).toBeFalsy();
+	});
+});

+ 31 - 31
frontend/src/components/LongJobs.vue

@@ -31,38 +31,38 @@ const remove = job => {
 };
 
 onMounted(() => {
-	if (loggedIn.value) {
-		socket.dispatch("users.getLongJobs", {
-			cb: res => {
-				if (res.status === "success") {
-					setJobs(res.data.longJobs);
-				} else console.log(res.message);
-			},
-			onProgress: res => {
-				setJob(res);
-			}
-		});
+	socket.onConnect(() => {
+		if (loggedIn.value) {
+			socket.dispatch("users.getLongJobs", {
+				cb: res => {
+					if (res.status === "success") {
+						setJobs(res.data.longJobs);
+					} else console.log(res.message);
+				},
+				onProgress: res => {
+					setJob(res);
+				}
+			});
+		}
+	});
 
-		socket.on("keep.event:longJob.removed", ({ data }) => {
-			removeJob(data.jobId);
-		});
+	socket.on("keep.event:longJob.removed", ({ data }) => {
+		removeJob(data.jobId);
+	});
 
-		socket.on("keep.event:longJob.added", ({ data }) => {
-			if (
-				!activeJobs.value.find(activeJob => activeJob.id === data.jobId)
-			)
-				socket.dispatch("users.getLongJob", data.jobId, {
-					cb: res => {
-						if (res.status === "success") {
-							setJob(res.data.longJob);
-						} else console.log(res.message);
-					},
-					onProgress: res => {
-						setJob(res);
-					}
-				});
-		});
-	}
+	socket.on("keep.event:longJob.added", ({ data }) => {
+		if (!activeJobs.value.find(activeJob => activeJob.id === data.jobId))
+			socket.dispatch("users.getLongJob", data.jobId, {
+				cb: res => {
+					if (res.status === "success") {
+						setJob(res.data.longJob);
+					} else console.log(res.message);
+				},
+				onProgress: res => {
+					setJob(res);
+				}
+			});
+	});
 });
 </script>
 
@@ -134,7 +134,7 @@ onMounted(() => {
 							ref="longJobMessage"
 							:append-to="body"
 						>
-							<i class="material-icons">chat</i>
+							<i class="material-icons message">chat</i>
 
 							<template #content>
 								<div class="long-job-message">

+ 17 - 14
frontend/src/components/MainHeader.vue

@@ -29,19 +29,21 @@ const siteSettings = ref({
 	registrationDisabled: false
 });
 const windowWidth = ref(0);
+const sidName = ref();
+const broadcastChannel = ref();
 
 const { socket } = useWebsocketsStore();
 
-const { loggedIn, username, role } = storeToRefs(userAuthStore);
-const { logout } = userAuthStore;
-const { changeNightmode } = useUserPreferencesStore();
+const { loggedIn, username } = storeToRefs(userAuthStore);
+const { logout, hasPermission } = userAuthStore;
+const userPreferencesStore = useUserPreferencesStore();
+const { nightmode } = storeToRefs(userPreferencesStore);
 
 const { openModal } = useModalsStore();
 
 const toggleNightmode = toggle => {
-	localNightmode.value = toggle || !localNightmode.value;
-
-	localStorage.setItem("nightmode", `${localNightmode.value}`);
+	localNightmode.value =
+		toggle === undefined ? !localNightmode.value : toggle;
 
 	if (loggedIn.value) {
 		socket.dispatch(
@@ -51,29 +53,30 @@ const toggleNightmode = toggle => {
 				if (res.status !== "success") new Toast(res.message);
 			}
 		);
+	} else {
+		broadcastChannel.value.postMessage(localNightmode.value);
 	}
-
-	changeNightmode(localNightmode.value);
 };
 
 const onResize = () => {
 	windowWidth.value = window.innerWidth;
 };
 
-watch(localNightmode, nightmode => {
-	if (localNightmode.value !== nightmode) toggleNightmode(nightmode);
+watch(nightmode, value => {
+	localNightmode.value = value;
 });
 
 onMounted(async () => {
-	localNightmode.value = JSON.parse(localStorage.getItem("nightmode"));
-	if (localNightmode.value === null) localNightmode.value = false;
-
+	localNightmode.value = nightmode.value;
 	frontendDomain.value = await lofig.get("frontendDomain");
 	siteSettings.value = await lofig.get("siteSettings");
+	sidName.value = await lofig.get("cookie.SIDname");
 
 	await nextTick();
 	onResize();
 	window.addEventListener("resize", onResize);
+
+	broadcastChannel.value = new BroadcastChannel(`${sidName.value}.nightmode`);
 });
 </script>
 
@@ -129,7 +132,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"
 				>

+ 63 - 0
frontend/src/components/Modal.spec.ts

@@ -0,0 +1,63 @@
+import { flushPromises } from "@vue/test-utils";
+import { h } from "vue";
+import { getWrapper } from "@/tests/utils/utils";
+import { useModalsStore } from "@/stores/modals";
+import Modal from "@/components/Modal.vue";
+
+describe("Modal component", () => {
+	beforeEach(async context => {
+		context.wrapper = await getWrapper(Modal);
+	});
+
+	test("title prop", async ({ wrapper }) => {
+		const title = "Modal Title";
+		await wrapper.setProps({ title });
+		expect(wrapper.find(".modal-card-title").text()).toBe(title);
+	});
+
+	test("size prop", async ({ wrapper }) => {
+		await wrapper.setProps({ size: "slim" });
+		expect(wrapper.find(".modal-card").classes()).toContain("modal-slim");
+
+		await wrapper.setProps({ size: "wide" });
+		expect(wrapper.find(".modal-card").classes()).toContain("modal-wide");
+	});
+
+	test("split prop", async ({ wrapper }) => {
+		expect(wrapper.find(".modal-card").classes()).not.toContain(
+			"modal-split"
+		);
+		await wrapper.setProps({ split: true });
+		expect(wrapper.find(".modal-card").classes()).toContain("modal-split");
+	});
+
+	test("christmas lights render if enabled", async () => {
+		const wrapper = await getWrapper(Modal, {
+			shallow: true,
+			lofig: { siteSettings: { christmas: true } }
+		});
+		expect(
+			wrapper.findComponent({ name: "ChristmasLights" }).exists()
+		).toBeTruthy();
+	});
+
+	test("click to close modal calls store action", async ({ wrapper }) => {
+		const modalsStore = useModalsStore();
+		await wrapper.find(".modal-background").trigger("click");
+		await wrapper.find(".modal-card-head > .delete").trigger("click");
+		await flushPromises();
+		expect(modalsStore.closeCurrentModal).toHaveBeenCalledTimes(2);
+	});
+
+	test("renders slots", async () => {
+		const wrapper = await getWrapper(Modal, {
+			slots: {
+				sidebar: h("div", {}, "Sidebar Slot"),
+				toggleMobileSidebar: h("div", {}, "Toggle Mobile Sidebar Slot"),
+				body: h("div", {}, "Body Slot"),
+				footer: h("div", {}, "Footer Slot")
+			}
+		});
+		expect(wrapper.html()).toMatchSnapshot();
+	});
+});

+ 4 - 14
frontend/src/components/Modal.vue

@@ -6,24 +6,16 @@ const ChristmasLights = defineAsyncComponent(
 	() => import("@/components/ChristmasLights.vue")
 );
 
-const props = defineProps({
+defineProps({
 	title: { type: String, default: "Modal" },
 	size: { type: String, default: null },
-	split: { type: Boolean, default: false },
-	interceptClose: { type: Boolean, default: false }
+	split: { type: Boolean, default: false }
 });
 
-const emit = defineEmits(["close"]);
-
 const christmas = ref(false);
 
 const { closeCurrentModal } = useModalsStore();
 
-const closeCurrentModalClick = () => {
-	if (props.interceptClose) emit("close");
-	else closeCurrentModal();
-};
-
 onMounted(async () => {
 	christmas.value = await lofig.get("siteSettings.christmas");
 });
@@ -31,7 +23,7 @@ onMounted(async () => {
 
 <template>
 	<div class="modal is-active">
-		<div class="modal-background" @click="closeCurrentModalClick()" />
+		<div class="modal-background" @click="closeCurrentModal()" />
 		<slot name="sidebar" />
 		<div
 			:class="{
@@ -46,9 +38,7 @@ onMounted(async () => {
 				<h2 class="modal-card-title is-marginless">
 					{{ title }}
 				</h2>
-				<span
-					class="delete material-icons"
-					@click="closeCurrentModalClick()"
+				<span class="delete material-icons" @click="closeCurrentModal()"
 					>highlight_off</span
 				>
 				<christmas-lights v-if="christmas" small :lights="5" />

+ 4 - 2
frontend/src/components/ModalManager.vue

@@ -26,7 +26,8 @@ const modalComponents = shallowRef(
 		importAlbum: "ImportAlbum.vue",
 		confirm: "Confirm.vue",
 		editSong: "EditSong/index.vue",
-		viewYoutubeVideo: "ViewYoutubeVideo.vue"
+		viewYoutubeVideo: "ViewYoutubeVideo.vue",
+		bulkEditPlaylist: "BulkEditPlaylist.vue"
 	})
 );
 </script>
@@ -35,8 +36,9 @@ const modalComponents = shallowRef(
 	<div>
 		<div v-for="activeModalUuid in activeModals" :key="activeModalUuid">
 			<component
-				:is="modalComponents[modals[activeModalUuid]]"
+				:is="modalComponents[modals[activeModalUuid].modal]"
 				:modal-uuid="activeModalUuid"
+				v-bind="modals[activeModalUuid].props"
 			/>
 		</div>
 	</div>

+ 53 - 56
frontend/src/components/PlaylistTabBase.vue

@@ -2,11 +2,9 @@
 import { defineAsyncComponent, ref, reactive, computed, onMounted } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
-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";
@@ -21,7 +19,7 @@ const QuickConfirm = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" },
+	modalUuid: { type: String, default: null },
 	type: {
 		type: String,
 		default: ""
@@ -36,7 +34,6 @@ const emit = defineEmits(["selected"]);
 
 const { socket } = useWebsocketsStore();
 const stationStore = useStationStore();
-const userAuthStore = useUserAuthStore();
 
 const tab = ref("current");
 const search = reactive({
@@ -61,10 +58,11 @@ const {
 	calculatePlaylistOrder
 } = useSortablePlaylists();
 
-const { loggedIn, role, userId } = storeToRefs(userAuthStore);
 const { autoRequest } = storeToRefs(stationStore);
 
-const manageStationStore = useManageStationStore(props);
+const manageStationStore = useManageStationStore({
+	modalUuid: props.modalUuid
+});
 const { autofill } = storeToRefs(manageStationStore);
 
 const station = computed({
@@ -98,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();
@@ -105,49 +108,11 @@ const { setPlaylists } = useUserPlaylistsStore();
 const { addPlaylistToAutoRequest, removePlaylistFromAutoRequest } =
 	stationStore;
 
-const init = () => {
-	socket.dispatch("playlists.indexMyPlaylists", res => {
-		if (res.status === "success") setPlaylists(res.data.playlists);
-		orderOfPlaylists.value = calculatePlaylistOrder(); // order in regards to the database
-	});
-
-	socket.dispatch("playlists.indexFeaturedPlaylists", res => {
-		if (res.status === "success")
-			featuredPlaylists.value = res.data.playlists;
-	});
-
-	if (props.type === "autofill")
-		socket.dispatch(
-			`stations.getStationAutofillPlaylistsById`,
-			station.value._id,
-			res => {
-				if (res.status === "success") {
-					station.value.autofill.playlists = res.data.playlists;
-				}
-			}
-		);
-
-	socket.dispatch(
-		`stations.getStationBlacklistById`,
-		station.value._id,
-		res => {
-			if (res.status === "success") {
-				station.value.blacklist = res.data.playlists;
-			}
-		}
-	);
-};
-
 const showTab = _tab => {
 	tabs.value[`${_tab}-tab`].scrollIntoView({ block: "nearest" });
 	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;
 
@@ -299,7 +264,38 @@ const searchForPlaylists = page => {
 onMounted(() => {
 	showTab("search");
 
-	ws.onConnect(init);
+	socket.onConnect(() => {
+		socket.dispatch("playlists.indexMyPlaylists", res => {
+			if (res.status === "success") setPlaylists(res.data.playlists);
+			orderOfPlaylists.value = calculatePlaylistOrder(); // order in regards to the database
+		});
+
+		socket.dispatch("playlists.indexFeaturedPlaylists", res => {
+			if (res.status === "success")
+				featuredPlaylists.value = res.data.playlists;
+		});
+
+		if (props.type === "autofill")
+			socket.dispatch(
+				`stations.getStationAutofillPlaylistsById`,
+				station.value._id,
+				res => {
+					if (res.status === "success") {
+						station.value.autofill.playlists = res.data.playlists;
+					}
+				}
+			);
+
+		socket.dispatch(
+			`stations.getStationBlacklistById`,
+			station.value._id,
+			res => {
+				if (res.status === "success") {
+					station.value.blacklist = res.data.playlists;
+				}
+			}
+		);
+	});
 });
 </script>
 
@@ -498,7 +494,7 @@ onMounted(() => {
 								@click="
 									openModal({
 										modal: 'editPlaylist',
-										data: {
+										props: {
 											playlistId: featuredPlaylist._id
 										}
 									})
@@ -512,12 +508,12 @@ onMounted(() => {
 								v-if="
 									featuredPlaylist.createdBy !== myUserId &&
 									(featuredPlaylist.privacy === 'public' ||
-										isAdmin())
+										hasPermission('playlists.view.others'))
 								"
 								@click="
 									openModal({
 										modal: 'editPlaylist',
-										data: {
+										props: {
 											playlistId: featuredPlaylist._id
 										}
 									})
@@ -682,7 +678,7 @@ onMounted(() => {
 								@click="
 									openModal({
 										modal: 'editPlaylist',
-										data: { playlistId: playlist._id }
+										props: { playlistId: playlist._id }
 									})
 								"
 								class="material-icons edit-icon"
@@ -693,12 +689,13 @@ onMounted(() => {
 							<i
 								v-if="
 									playlist.createdBy !== myUserId &&
-									(playlist.privacy === 'public' || isAdmin())
+									(playlist.privacy === 'public' ||
+										hasPermission('playlists.view.others'))
 								"
 								@click="
 									openModal({
 										modal: 'editPlaylist',
-										data: { playlistId: playlist._id }
+										props: { playlistId: playlist._id }
 									})
 								"
 								class="material-icons edit-icon"
@@ -746,7 +743,6 @@ onMounted(() => {
 
 						<template #actions>
 							<quick-confirm
-								v-if="isOwnerOrAdmin()"
 								@confirm="deselectPlaylist(playlist._id)"
 							>
 								<i
@@ -764,7 +760,7 @@ onMounted(() => {
 								@click="
 									openModal({
 										modal: 'editPlaylist',
-										data: { playlistId: playlist._id }
+										props: { playlistId: playlist._id }
 									})
 								"
 								class="material-icons edit-icon"
@@ -775,12 +771,13 @@ onMounted(() => {
 							<i
 								v-if="
 									playlist.createdBy !== myUserId &&
-									(playlist.privacy === 'public' || isAdmin())
+									(playlist.privacy === 'public' ||
+										hasPermission('playlists.view.others'))
 								"
 								@click="
 									openModal({
 										modal: 'editPlaylist',
-										data: { playlistId: playlist._id }
+										props: { playlistId: playlist._id }
 									})
 								"
 								class="material-icons edit-icon"
@@ -954,7 +951,7 @@ onMounted(() => {
 										@click="
 											openModal({
 												modal: 'editPlaylist',
-												data: {
+												props: {
 													playlistId: element._id
 												}
 											})

+ 18 - 3
frontend/src/components/PunishmentItem.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { defineAsyncComponent, computed } from "vue";
 import { format, formatDistance, parseISO } from "date-fns";
+import { useUserAuthStore } from "@/stores/userAuth";
 
 const UserLink = defineAsyncComponent(
 	() => import("@/components/UserLink.vue")
@@ -12,6 +13,8 @@ const props = defineProps({
 
 defineEmits(["deactivate"]);
 
+const { hasPermission } = useUserAuthStore();
+
 const active = computed(
 	() =>
 		props.punishment.active &&
@@ -23,19 +26,31 @@ const active = computed(
 	<div class="universal-item punishment-item">
 		<div class="item-icon">
 			<p class="is-expanded checkbox-control">
-				<label class="switch" :class="{ disabled: !active }">
+				<label
+					class="switch"
+					:class="{
+						disabled: !(
+							hasPermission('punishments.deactivate') && active
+						)
+					}"
+				>
 					<input
 						type="checkbox"
 						:checked="active"
 						@click="
-							active
+							hasPermission('punishments.deactivate') && active
 								? $emit('deactivate', $event)
 								: $event.preventDefault()
 						"
 					/>
 					<span
 						class="slider round"
-						:class="{ disabled: !active }"
+						:class="{
+							disabled: !(
+								hasPermission('punishments.deactivate') &&
+								active
+							)
+						}"
 					></span>
 				</label>
 			</p>

+ 11 - 14
frontend/src/components/Queue.vue

@@ -1,11 +1,9 @@
 <script setup lang="ts">
 import { defineAsyncComponent, ref, computed, onUpdated } from "vue";
 import Toast from "toasters";
-import { storeToRefs } from "pinia";
 import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useStationStore } from "@/stores/station";
-import { useUserAuthStore } from "@/stores/userAuth";
 import { useManageStationStore } from "@/stores/manageStation";
 
 const SongItem = defineAsyncComponent(
@@ -16,16 +14,15 @@ const QuickConfirm = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" },
+	modalUuid: { type: String, default: null },
 	sector: { type: String, default: "station" }
 });
 
 const { socket } = useWebsocketsStore();
 const stationStore = useStationStore();
-const userAuthStore = useUserAuthStore();
-const manageStationStore = useManageStationStore(props);
-
-const { loggedIn, userId, role: userRole } = storeToRefs(userAuthStore);
+const manageStationStore = useManageStationStore({
+	modalUuid: props.modalUuid
+});
 
 const actionableButtonVisible = ref(false);
 const drag = ref(false);
@@ -56,10 +53,10 @@ 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 removeFromQueue = youtubeId => {
 	socket.dispatch(
@@ -148,7 +145,7 @@ onUpdated(() => {
 				@start="drag = true"
 				@end="drag = false"
 				@update="repositionSongInQueue"
-				:disabled="!(isAdminOnly() || isOwnerOnly())"
+				:disabled="!hasPermission('stations.queue.reposition')"
 			>
 				<template #item="{ element, index }">
 					<song-item
@@ -158,11 +155,11 @@ onUpdated(() => {
 						: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)"
 							>

+ 0 - 4
frontend/src/components/ReportInfoItem.vue

@@ -1,7 +1,6 @@
 <script setup lang="ts">
 import { defineAsyncComponent } from "vue";
 import { formatDistance } from "date-fns";
-import { useModalsStore } from "@/stores/modals";
 
 const ProfilePicture = defineAsyncComponent(
 	() => import("@/components/ProfilePicture.vue")
@@ -11,8 +10,6 @@ defineProps({
 	createdBy: { type: Object, default: () => {} },
 	createdAt: { type: String, default: "" }
 });
-
-const { closeModal } = useModalsStore();
 </script>
 
 <template>
@@ -40,7 +37,6 @@ const { closeModal } = useModalsStore();
 						path: `/u/${createdBy.username}`
 					}"
 					:title="createdBy._id"
-					@click="closeModal('viewReport')"
 				>
 					{{ createdBy.username }}
 				</router-link>

+ 4 - 2
frontend/src/components/Request.vue

@@ -18,7 +18,7 @@ const PlaylistTabBase = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" },
+	modalUuid: { type: String, default: null },
 	sector: { type: String, default: "station" },
 	disableAutoRequest: { type: Boolean, default: false }
 });
@@ -28,7 +28,9 @@ const { musareSearch, searchForMusareSongs } = useSearchMusare();
 
 const { socket } = useWebsocketsStore();
 const stationStore = useStationStore();
-const manageStationStore = useManageStationStore(props);
+const manageStationStore = useManageStationStore({
+	modalUuid: props.modalUuid
+});
 
 const tab = ref("songs");
 const sitename = ref("Musare");

+ 1 - 0
frontend/src/components/RunJobDropdown.vue

@@ -42,6 +42,7 @@ const runJob = job => {
 
 <template>
 	<tippy
+		v-if="jobs.length > 0"
 		class="runJobDropdown"
 		:touch="true"
 		:interactive="true"

+ 1 - 1
frontend/src/components/SaveButton.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { ref, computed, defineExpose } from "vue";
+import { ref, computed } from "vue";
 
 const props = defineProps({
 	defaultMessage: { type: String, default: "Save Changes" }

+ 16 - 12
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();
 
@@ -90,16 +91,26 @@ const hoverTippy = () => {
 	hoveredTippy.value = true;
 };
 
+const viewYoutubeVideo = youtubeId => {
+	hideTippyElements();
+	openModal({
+		modal: "viewYoutubeVideo",
+		props: {
+			youtubeId
+		}
+	});
+};
+
 const report = song => {
 	hideTippyElements();
-	openModal({ modal: "report", data: { song } });
+	openModal({ modal: "report", props: { song } });
 };
 
 const edit = song => {
 	hideTippyElements();
 	openModal({
 		modal: "editSong",
-		data: { song }
+		props: { song }
 	});
 };
 
@@ -200,14 +211,7 @@ onUnmounted(() => {
 						<div class="icons-group">
 							<i
 								v-if="disabledActions.indexOf('youtube') === -1"
-								@click="
-									openModal({
-										modal: 'viewYoutubeVideo',
-										data: {
-											youtubeId: song.youtubeId
-										}
-									})
-								"
+								@click="viewYoutubeVideo(song.youtubeId)"
 								content="View YouTube Video"
 								v-tippy
 							>
@@ -246,7 +250,7 @@ onUnmounted(() => {
 								v-if="
 									loggedIn &&
 									song._id &&
-									userRole === 'admin' &&
+									hasPermission('songs.update') &&
 									disabledActions.indexOf('edit') === -1
 								"
 								class="material-icons edit-icon"

+ 27 - 15
frontend/src/components/StationInfoBox.vue

@@ -4,27 +4,33 @@ 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: null },
+	sector: { type: String, default: "station" }
+});
+
+const userAuthStore = useUserAuthStore();
+const stationStore = useStationStore();
+const manageStationStore = useManageStationStore({
+	modalUuid: props.modalUuid
 });
 
 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 +110,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 +121,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 +133,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>
@@ -135,13 +145,15 @@ const unfavoriteStation = () => {
 				@click="
 					openModal({
 						modal: 'manageStation',
-						data: {
+						props: {
 							stationId: station._id,
 							sector: 'station'
 						}
 					})
 				"
-				v-if="isOwnerOrAdmin() && showManageStation"
+				v-if="
+					hasPermission('stations.view.manage') && showManageStation
+				"
 			>
 				<i class="material-icons icon-with-button">settings</i>
 				<span> Manage Station </span>

+ 8 - 7
frontend/src/components/UserLink.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import { ref, onMounted } from "vue";
+import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 
 const props = defineProps({
@@ -7,16 +8,16 @@ const props = defineProps({
 	link: { type: Boolean, default: true }
 });
 
-const user = ref({
-	name: "Unknown",
-	username: null
+const user = ref<{ name: string; username?: string }>({
+	name: "Unknown"
 });
 
+const { socket } = useWebsocketsStore();
 const { getBasicUser } = useUserAuthStore();
 
 onMounted(() => {
-	getBasicUser(props.userId).then(
-		(basicUser: { name: string; username: string } | null) => {
+	socket.onConnect(() => {
+		getBasicUser(props.userId).then(basicUser => {
 			if (basicUser) {
 				const { name, username } = basicUser;
 				user.value = {
@@ -24,8 +25,8 @@ onMounted(() => {
 					username
 				};
 			}
-		}
-	);
+		});
+	});
 });
 </script>
 

+ 21 - 0
frontend/src/components/__snapshots__/Modal.spec.ts.snap

@@ -0,0 +1,21 @@
+// Vitest Snapshot v1
+
+exports[`Modal component > renders slots 1`] = `
+"<div class=\\"modal is-active\\">
+  <div class=\\"modal-background\\"></div>
+  <div>Sidebar Slot</div>
+  <div class=\\"modal-card\\">
+    <header class=\\"modal-card-head\\">
+      <div>Toggle Mobile Sidebar Slot</div>
+      <h2 class=\\"modal-card-title is-marginless\\">Modal</h2><span class=\\"delete material-icons\\">highlight_off</span>
+      <!--v-if-->
+    </header>
+    <section class=\\"modal-card-body\\">
+      <div>Body Slot</div>
+    </section>
+    <footer class=\\"modal-card-foot\\">
+      <div>Footer Slot</div>
+    </footer>
+  </div>
+</div>"
+`;

+ 18 - 27
frontend/src/components/modals/BulkActions.vue

@@ -1,12 +1,9 @@
 <script setup lang="ts">
 import { ref, defineAsyncComponent, onMounted, onBeforeUnmount } from "vue";
 import Toast from "toasters";
-import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useLongJobsStore } from "@/stores/longJobs";
-import { useBulkActionsStore } from "@/stores/bulkActions";
 import { useModalsStore } from "@/stores/modals";
-import ws from "@/ws";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const AutoSuggest = defineAsyncComponent(
@@ -14,7 +11,8 @@ const AutoSuggest = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	type: { type: Object, required: true }
 });
 
 const { closeCurrentModal } = useModalsStore();
@@ -23,32 +21,17 @@ const { setJob } = useLongJobsStore();
 
 const { socket } = useWebsocketsStore();
 
-const bulkActionsStore = useBulkActionsStore(props);
-const { type } = storeToRefs(bulkActionsStore);
-
 const method = ref("add");
 const items = ref([]);
 const itemInput = ref();
 const allItems = ref([]);
 
-const init = () => {
-	if (type.value.autosuggest && type.value.autosuggestDataAction)
-		socket.dispatch(type.value.autosuggestDataAction, res => {
-			if (res.status === "success") {
-				const { items } = res.data;
-				allItems.value = items;
-			} else {
-				new Toast(res.message);
-			}
-		});
-};
-
 const addItem = () => {
 	if (!itemInput.value) return;
-	if (type.value.regex && !type.value.regex.test(itemInput.value)) {
-		new Toast(`Invalid ${type.value.name} format.`);
+	if (props.type.regex && !props.type.regex.test(itemInput.value)) {
+		new Toast(`Invalid ${props.type.name} format.`);
 	} else if (items.value.includes(itemInput.value)) {
-		new Toast(`Duplicate ${type.value.name} specified.`);
+		new Toast(`Duplicate ${props.type.name} specified.`);
 	} else {
 		items.value.push(itemInput.value);
 		itemInput.value = null;
@@ -64,10 +47,10 @@ const applyChanges = () => {
 	let title;
 
 	socket.dispatch(
-		type.value.action,
+		props.type.action,
 		method.value,
 		items.value,
-		type.value.items,
+		props.type.items,
 		{
 			cb: () => {},
 			onProgress: res => {
@@ -91,12 +74,20 @@ const applyChanges = () => {
 onBeforeUnmount(() => {
 	itemInput.value = null;
 	items.value = [];
-	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
-	bulkActionsStore.$dispose();
 });
 
 onMounted(() => {
-	ws.onConnect(init);
+	socket.onConnect(() => {
+		if (props.type.autosuggest && props.type.autosuggestDataAction)
+			socket.dispatch(props.type.autosuggestDataAction, res => {
+				if (res.status === "success") {
+					const { items } = res.data;
+					allItems.value = items;
+				} else {
+					new Toast(res.message);
+				}
+			});
+	});
 });
 </script>
 

+ 239 - 0
frontend/src/components/modals/BulkEditPlaylist.vue

@@ -0,0 +1,239 @@
+<script setup lang="ts">
+import { reactive, computed, defineAsyncComponent } from "vue";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useLongJobsStore } from "@/stores/longJobs";
+import { useModalsStore } from "@/stores/modals";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const PlaylistItem = defineAsyncComponent(
+	() => import("@/components/PlaylistItem.vue")
+);
+const QuickConfirm = defineAsyncComponent(
+	() => import("@/components/QuickConfirm.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, required: true },
+	youtubeIds: { type: Array, required: true }
+});
+
+const { closeCurrentModal } = useModalsStore();
+
+const { setJob } = useLongJobsStore();
+
+const { socket } = useWebsocketsStore();
+
+const { openModal } = useModalsStore();
+
+const search = reactive({
+	query: "",
+	searchedQuery: "",
+	page: 0,
+	count: 0,
+	resultsLeft: 0,
+	pageSize: 0,
+	results: []
+});
+
+const resultsLeftCount = computed(() => search.count - search.results.length);
+
+const nextPageResultsCount = computed(() =>
+	Math.min(search.pageSize, resultsLeftCount.value)
+);
+
+const searchForPlaylists = page => {
+	if (search.page >= page || search.searchedQuery !== search.query) {
+		search.results = [];
+		search.page = 0;
+		search.count = 0;
+		search.resultsLeft = 0;
+		search.pageSize = 0;
+	}
+
+	const { query } = search;
+	const action = "playlists.searchAdmin";
+
+	search.searchedQuery = search.query;
+	socket.dispatch(action, query, page, res => {
+		const { data } = res;
+		if (res.status === "success") {
+			const { count, pageSize, playlists } = data;
+			search.results = [...search.results, ...playlists];
+			search.page = page;
+			search.count = count;
+			search.resultsLeft = count - search.results.length;
+			search.pageSize = pageSize;
+		} else if (res.status === "error") {
+			search.results = [];
+			search.page = 0;
+			search.count = 0;
+			search.resultsLeft = 0;
+			search.pageSize = 0;
+			new Toast(res.message);
+		}
+	});
+};
+
+const addSongsToPlaylist = playlistId => {
+	let id;
+	let title;
+
+	socket.dispatch(
+		"playlists.addSongsToPlaylist",
+		playlistId,
+		props.youtubeIds,
+		{
+			cb: () => {},
+			onProgress: res => {
+				if (res.status === "started") {
+					id = res.id;
+					title = res.title;
+					closeCurrentModal();
+				}
+
+				if (id)
+					setJob({
+						id,
+						name: title,
+						...res
+					});
+			}
+		}
+	);
+};
+
+const removeSongsFromPlaylist = playlistId => {
+	let id;
+	let title;
+
+	socket.dispatch(
+		"playlists.removeSongsFromPlaylist",
+		playlistId,
+		props.youtubeIds,
+		{
+			cb: data => {
+				console.log("FINISHED", data);
+			},
+			onProgress: res => {
+				if (res.status === "started") {
+					id = res.id;
+					title = res.title;
+					closeCurrentModal();
+				}
+
+				if (id)
+					setJob({
+						id,
+						name: title,
+						...res
+					});
+			}
+		}
+	);
+};
+</script>
+
+<template>
+	<div>
+		<modal
+			title="Bulk Edit Playlist"
+			class="bulk-edit-playlist-modal"
+			size="slim"
+		>
+			<template #body>
+				<div>
+					<label class="label">Search for a playlist</label>
+					<div class="control is-grouped input-with-button">
+						<p class="control is-expanded">
+							<input
+								class="input"
+								type="text"
+								placeholder="Enter your playlist query here..."
+								v-model="search.query"
+								@keyup.enter="searchForPlaylists(1)"
+							/>
+						</p>
+						<p class="control">
+							<a
+								class="button is-info"
+								@click="searchForPlaylists(1)"
+								><i class="material-icons icon-with-button"
+									>search</i
+								>Search</a
+							>
+						</p>
+					</div>
+					<div v-if="search.results.length > 0">
+						<playlist-item
+							v-for="playlist in search.results"
+							:key="`searchKey-${playlist._id}`"
+							:playlist="playlist"
+							:show-owner="true"
+						>
+							<template #actions>
+								<quick-confirm
+									@confirm="addSongsToPlaylist(playlist._id)"
+								>
+									<i
+										class="material-icons add-to-playlist-icon"
+										:content="`Add songs to playlist`"
+										v-tippy
+									>
+										playlist_add
+									</i>
+								</quick-confirm>
+								<quick-confirm
+									@confirm="
+										removeSongsFromPlaylist(playlist._id)
+									"
+								>
+									<i
+										class="material-icons remove-from-playlist-icon"
+										:content="`Remove songs from playlist`"
+										v-tippy
+									>
+										playlist_remove
+									</i>
+								</quick-confirm>
+								<i
+									@click="
+										openModal({
+											modal: 'editPlaylist',
+											props: { playlistId: playlist._id }
+										})
+									"
+									class="material-icons edit-icon"
+									content="Edit Playlist"
+									v-tippy
+									>edit</i
+								>
+							</template>
+						</playlist-item>
+						<button
+							v-if="resultsLeftCount > 0"
+							class="button is-primary load-more-button"
+							@click="searchForPlaylists(search.page + 1)"
+						>
+							Load {{ nextPageResultsCount }} more results
+						</button>
+					</div>
+				</div>
+			</template>
+		</modal>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.label {
+	text-transform: capitalize;
+}
+
+.playlist-item:not(:last-of-type) {
+	margin-bottom: 10px;
+}
+.load-more-button {
+	width: 100%;
+	margin-top: 10px;
+}
+</style>

+ 5 - 14
frontend/src/components/modals/Confirm.vue

@@ -1,30 +1,21 @@
 <script setup lang="ts">
-import { defineAsyncComponent, onBeforeUnmount } from "vue";
-import { storeToRefs } from "pinia";
-import { useConfirmStore } from "@/stores/confirm";
+import { defineAsyncComponent } from "vue";
 import { useModalsStore } from "@/stores/modals";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	message: { type: [String, Array], required: true },
+	onCompleted: { type: Function, required: true }
 });
 
-const confirmStore = useConfirmStore(props);
-const { message } = storeToRefs(confirmStore);
-const { confirm } = confirmStore;
-
 const { closeCurrentModal } = useModalsStore();
 
 const confirmAction = () => {
-	confirm();
+	props.onCompleted();
 	closeCurrentModal();
 };
-
-onBeforeUnmount(() => {
-	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
-	confirmStore.$dispose();
-});
 </script>
 
 <template>

+ 21 - 13
frontend/src/components/modals/CreatePlaylist.vue

@@ -7,8 +7,9 @@ import { useModalsStore } from "@/stores/modals";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 
-defineProps({
-	modalUuid: { type: String, default: "" }
+const props = defineProps({
+	modalUuid: { type: String, required: true },
+	admin: { type: Boolean, default: false }
 });
 
 const playlist = ref({
@@ -31,20 +32,24 @@ const createPlaylist = () => {
 			"Invalid display name format. Only ASCII characters are allowed."
 		);
 
-	return socket.dispatch("playlists.create", playlist.value, res => {
-		new Toast(res.message);
+	return socket.dispatch(
+		"playlists.create",
+		{ ...playlist.value, admin: props.admin },
+		res => {
+			new Toast(res.message);
 
-		if (res.status === "success") {
-			closeCurrentModal();
+			if (res.status === "success") {
+				closeCurrentModal();
 
-			if (!window.addToPlaylistDropdown) {
-				openModal({
-					modal: "editPlaylist",
-					data: { playlistId: res.data.playlistId }
-				});
+				if (!window.addToPlaylistDropdown) {
+					openModal({
+						modal: "editPlaylist",
+						props: { playlistId: res.data.playlistId }
+					});
+				}
 			}
 		}
-	});
+	);
 };
 
 onBeforeUnmount(() => {
@@ -59,7 +64,10 @@ onBeforeUnmount(() => {
 </script>
 
 <template>
-	<modal title="Create Playlist" :size="'slim'">
+	<modal
+		:title="admin ? 'Create Admin Playlist' : 'Create Playlist'"
+		:size="'slim'"
+	>
 		<template #body>
 			<p class="control is-expanded">
 				<label class="label">Display Name</label>

+ 4 - 13
frontend/src/components/modals/CreateStation.vue

@@ -1,23 +1,19 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref, onBeforeUnmount } from "vue";
+import { defineAsyncComponent, ref } from "vue";
 import Toast from "toasters";
-import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
-import { useCreateStationStore } from "@/stores/createStation";
 import { useModalsStore } from "@/stores/modals";
 import validation from "@/validation";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	official: { type: Boolean, default: false }
 });
 
 const { socket } = useWebsocketsStore();
 
-const createStationStore = useCreateStationStore(props);
-const { official } = storeToRefs(createStationStore);
-
 const { closeCurrentModal } = useModalsStore();
 
 const newStation = ref({
@@ -64,7 +60,7 @@ const submitModal = () => {
 		"stations.create",
 		{
 			name,
-			type: official.value ? "official" : "community",
+			type: props.official ? "official" : "community",
 			displayName,
 			description
 		},
@@ -76,11 +72,6 @@ const submitModal = () => {
 		}
 	);
 };
-
-onBeforeUnmount(() => {
-	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
-	createStationStore.$dispose();
-});
 </script>
 
 <template>

+ 81 - 91
frontend/src/components/modals/EditNews.vue

@@ -1,14 +1,14 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref, onMounted, onBeforeUnmount } from "vue";
+import { defineAsyncComponent, ref, onMounted } from "vue";
 import { marked } from "marked";
 import DOMPurify from "dompurify";
 import Toast from "toasters";
 import { formatDistance } from "date-fns";
-import { storeToRefs } from "pinia";
+import { GetNewsResponse } from "@musare_types/actions/NewsActions";
+import { GenericResponse } from "@musare_types/actions/GenericActions";
 import { useWebsocketsStore } from "@/stores/websockets";
-import { useEditNewsStore } from "@/stores/editNews";
 import { useModalsStore } from "@/stores/modals";
-import ws from "@/ws";
+import { useForm } from "@/composables/useForm";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const SaveButton = defineAsyncComponent(
@@ -19,116 +19,83 @@ const UserLink = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	createNews: { type: Boolean, default: false },
+	newsId: { type: String, default: null },
+	sector: { type: String, default: "admin" }
 });
 
 const { socket } = useWebsocketsStore();
 
-const editNewsStore = useEditNewsStore(props);
-const { createNews, newsId } = storeToRefs(editNewsStore);
-
 const { closeCurrentModal } = useModalsStore();
 
-const markdown = ref(
-	"# Header\n## Sub-Header\n- **So**\n- _Many_\n- ~Points~\n\nOther things you want to say and [link](https://example.com).\n\n### Sub-Sub-Header\n> Oh look, a quote!\n\n`lil code`\n\n```\nbig code\n```\n"
-);
-const status = ref("published");
-const showToNewUsers = ref(false);
 const createdBy = ref();
 const createdAt = ref(0);
 
-const init = () => {
-	if (newsId && !createNews.value) {
-		socket.dispatch(`news.getNewsFromId`, newsId.value, res => {
-			if (res.status === "success") {
-				markdown.value = res.data.news.markdown;
-				status.value = res.data.news.status;
-				showToNewUsers.value = res.data.news.showToNewUsers;
-				createdBy.value = res.data.news.createdBy;
-				createdAt.value = res.data.news.createdAt;
-			} else {
-				new Toast("News with that ID not found.");
-				closeCurrentModal();
-			}
-		});
-	}
-};
-
 const getTitle = () => {
 	let title = "";
 	const preview = document.getElementById("preview");
 
 	// validate existence of h1 for the page title
 
-	if (preview.childNodes.length === 0) return "";
+	if (!preview || preview.childNodes.length === 0) return "";
 
 	if (preview.childNodes[0].nodeName !== "H1") {
 		for (let node = 0; node < preview.childNodes.length; node += 1) {
 			if (preview.childNodes[node].nodeName) {
 				if (preview.childNodes[node].nodeName === "H1")
-					title = preview.childNodes[node].textContent;
+					title = preview.childNodes[node].textContent || "";
 
 				break;
 			}
 		}
-	} else title = preview.childNodes[0].textContent;
+	} else title = preview.childNodes[0].textContent || "";
 
 	return title;
 };
 
-const create = close => {
-	if (markdown.value === "") return new Toast("News item cannot be empty.");
-
-	const title = getTitle();
-	if (!title)
-		return new Toast(
-			"Please provide a title (heading level 1) at the top of the document."
-		);
-
-	return socket.dispatch(
-		"news.create",
-		{
-			title,
-			markdown: markdown.value,
-			status: status.value,
-			showToNewUsers: showToNewUsers.value
-		},
-		res => {
-			new Toast(res.message);
-			if (res.status === "success" && close) closeCurrentModal();
-		}
-	);
-};
-
-const update = close => {
-	if (markdown.value === "") return new Toast("News item cannot be empty.");
-
-	const title = getTitle();
-	if (!title)
-		return new Toast(
-			"Please provide a title (heading level 1) at the top of the document."
-		);
-
-	return socket.dispatch(
-		"news.update",
-		newsId.value,
-		{
-			title,
-			markdown: markdown.value,
-			status: status.value,
-			showToNewUsers: showToNewUsers.value
+const { inputs, save, setOriginalValue } = useForm(
+	{
+		markdown: {
+			value: "# Header\n## Sub-Header\n- **So**\n- _Many_\n- ~Points~\n\nOther things you want to say and [link](https://example.com).\n\n### Sub-Sub-Header\n> Oh look, a quote!\n\n`lil code`\n\n```\nbig code\n```\n",
+			validate: (value: string) => {
+				if (value === "") return "News item cannot be empty.";
+				if (!getTitle())
+					return "Please provide a title (heading level 1) at the top of the document.";
+				return true;
+			}
 		},
-		res => {
-			new Toast(res.message);
-			if (res.status === "success" && close) closeCurrentModal();
+		status: "published",
+		showToNewUsers: false
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success") {
+			const data = {
+				title: getTitle(),
+				markdown: values.markdown,
+				status: values.status,
+				showToNewUsers: values.showToNewUsers
+			};
+			const cb = (res: GenericResponse) => {
+				new Toast(res.message);
+				if (res.status === "success") resolve();
+				else reject(new Error(res.message));
+			};
+			if (props.createNews) socket.dispatch("news.create", data, cb);
+			else socket.dispatch("news.update", props.newsId, data, cb);
+		} else {
+			if (status === "unchanged") new Toast(messages.unchanged);
+			else if (status === "error")
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
+			resolve();
 		}
-	);
-};
-
-onBeforeUnmount(() => {
-	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
-	editNewsStore.$dispose();
-});
+	},
+	{
+		modalUuid: props.modalUuid
+	}
+);
 
 onMounted(() => {
 	marked.use({
@@ -142,7 +109,28 @@ onMounted(() => {
 		}
 	});
 
-	ws.onConnect(init);
+	socket.onConnect(() => {
+		if (props.newsId && !props.createNews) {
+			socket.dispatch(
+				`news.getNewsFromId`,
+				props.newsId,
+				(res: GetNewsResponse) => {
+					if (res.status === "success") {
+						setOriginalValue({
+							markdown: res.data.news.markdown,
+							status: res.data.news.status,
+							showToNewUsers: res.data.news.showToNewUsers
+						});
+						createdBy.value = res.data.news.createdBy;
+						createdAt.value = res.data.news.createdAt;
+					} else {
+						new Toast("News with that ID not found.");
+						closeCurrentModal();
+					}
+				}
+			);
+		}
+	});
 });
 </script>
 
@@ -156,21 +144,23 @@ onMounted(() => {
 		<template #body>
 			<div class="left-section">
 				<p><strong>Markdown</strong></p>
-				<textarea v-model="markdown"></textarea>
+				<textarea v-model="inputs['markdown'].value"></textarea>
 			</div>
 			<div class="right-section">
 				<p><strong>Preview</strong></p>
 				<div
 					class="news-item"
 					id="preview"
-					v-html="DOMPurify.sanitize(marked(markdown))"
+					v-html="
+						DOMPurify.sanitize(marked(inputs['markdown'].value))
+					"
 				></div>
 			</div>
 		</template>
 		<template #footer>
 			<div>
 				<p class="control select">
-					<select v-model="status">
+					<select v-model="inputs['status'].value">
 						<option value="draft">Draft</option>
 						<option value="published" selected>Publish</option>
 					</select>
@@ -181,7 +171,7 @@ onMounted(() => {
 						<input
 							type="checkbox"
 							id="show-to-new-users"
-							v-model="showToNewUsers"
+							v-model="inputs['showToNewUsers'].value"
 						/>
 						<span class="slider round"></span>
 					</label>
@@ -194,13 +184,13 @@ onMounted(() => {
 				<save-button
 					ref="saveButton"
 					v-if="createNews"
-					@clicked="createNews ? create(false) : update(false)"
+					@clicked="save()"
 				/>
 
 				<save-button
 					ref="saveAndCloseButton"
 					default-message="Save and close"
-					@clicked="createNews ? create(true) : update(true)"
+					@clicked="save(closeCurrentModal)"
 				/>
 				<div class="right" v-if="createdAt > 0">
 					<span>

+ 5 - 5
frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue

@@ -13,11 +13,11 @@ const SearchQueryItem = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true }
 });
 
-const editPlaylistStore = useEditPlaylistStore(props);
-const { playlistId, playlist } = storeToRefs(editPlaylistStore);
+const editPlaylistStore = useEditPlaylistStore({ modalUuid: props.modalUuid });
+const { playlist } = storeToRefs(editPlaylistStore);
 
 const sitename = ref("Musare");
 
@@ -141,7 +141,7 @@ onMounted(async () => {
 								v-tippy
 								@click="
 									addMusareSongToPlaylist(
-										playlistId,
+										playlist._id,
 										song.youtubeId,
 										index
 									)
@@ -216,7 +216,7 @@ onMounted(async () => {
 								v-tippy
 								@click="
 									addYouTubeSongToPlaylist(
-										playlistId,
+										playlist._id,
 										result.id,
 										index
 									)

+ 2 - 2
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -7,12 +7,12 @@ import { useLongJobsStore } from "@/stores/longJobs";
 import { useEditPlaylistStore } from "@/stores/editPlaylist";
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true }
 });
 
 const { socket } = useWebsocketsStore();
 
-const editPlaylistStore = useEditPlaylistStore(props);
+const editPlaylistStore = useEditPlaylistStore({ modalUuid: props.modalUuid });
 const { playlist } = storeToRefs(editPlaylistStore);
 
 const { setJob } = useLongJobsStore();

+ 121 - 49
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -1,72 +1,144 @@
 <script setup lang="ts">
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { onBeforeUnmount, onMounted, watch } from "vue";
 import validation from "@/validation";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useEditPlaylistStore } from "@/stores/editPlaylist";
+import { useModalsStore } from "@/stores/modals";
+import { useForm } from "@/composables/useForm";
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true }
 });
 
 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 editPlaylistStore = useEditPlaylistStore({ modalUuid: props.modalUuid });
 const { playlist } = storeToRefs(editPlaylistStore);
 
-const isEditable = () =>
-	(playlist.value.type === "user" ||
+const { preventCloseUnsaved } = useModalsStore();
+
+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";
-
-const renamePlaylist = () => {
-	const { displayName } = playlist.value;
-	if (!validation.isLength(displayName, 2, 32))
-		return new Toast("Display name must have between 2 and 32 characters.");
-	if (!validation.regex.ascii.test(displayName))
-		return new Toast(
-			"Invalid display name format. Only ASCII characters are allowed."
-		);
-
-	return socket.dispatch(
-		"playlists.updateDisplayName",
-		playlist.value._id,
-		playlist.value.displayName,
-		res => {
-			new Toast(res.message);
-		}
-	);
-};
-
-const updatePrivacy = () => {
-	const { privacy } = playlist.value;
-	if (privacy === "public" || privacy === "private") {
-		socket.dispatch(
-			playlist.value.type === "genre"
-				? "playlists.updatePrivacyAdmin"
-				: "playlists.updatePrivacy",
-			playlist.value._id,
-			privacy,
-			res => {
-				new Toast(res.message);
+		playlist.value.type === "user-disliked" ||
+		playlist.value.type === "admin") &&
+		(isOwner() || hasPermission(permission))) ||
+	(playlist.value.type === "genre" &&
+		permission === "playlists.update.privacy" &&
+		hasPermission(permission));
+
+const {
+	inputs: displayNameInputs,
+	unsavedChanges: displayNameUnsaved,
+	save: saveDisplayName,
+	setOriginalValue: setDisplayName
+} = useForm(
+	{
+		displayName: {
+			value: playlist.value.displayName,
+			validate: value => {
+				if (!validation.isLength(value, 2, 32))
+					return "Display name must have between 2 and 32 characters.";
+				if (!validation.regex.ascii.test(value))
+					return "Invalid display name format. Only ASCII characters are allowed.";
+				return true;
 			}
-		);
+		}
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success")
+			socket.dispatch(
+				"playlists.updateDisplayName",
+				playlist.value._id,
+				values.displayName,
+				res => {
+					playlist.value.displayName = values.displayName;
+					if (res.status === "success") {
+						resolve();
+						new Toast(res.message);
+					} else reject(new Error(res.message));
+				}
+			);
+		else {
+			Object.values(messages).forEach(message => {
+				new Toast({ content: message, timeout: 8000 });
+			});
+			resolve();
+		}
+	},
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
 	}
-};
+);
+
+const {
+	inputs: privacyInputs,
+	unsavedChanges: privacyUnsaved,
+	save: savePrivacy,
+	setOriginalValue: setPrivacy
+} = useForm(
+	{ privacy: playlist.value.privacy },
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success")
+			socket.dispatch(
+				playlist.value.type === "genre" ||
+					playlist.value.type === "admin"
+					? "playlists.updatePrivacyAdmin"
+					: "playlists.updatePrivacy",
+				playlist.value._id,
+				values.privacy,
+				res => {
+					playlist.value.privacy = values.privacy;
+					if (res.status === "success") {
+						resolve();
+						new Toast(res.message);
+					} else reject(new Error(res.message));
+				}
+			);
+		else {
+			if (messages[status]) new Toast(messages[status]);
+			resolve();
+		}
+	},
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
+
+watch(playlist, (value, oldValue) => {
+	if (value.displayName !== oldValue.displayName)
+		setDisplayName({ displayName: value.displayName });
+	if (value.privacy !== oldValue.privacy)
+		setPrivacy({ privacy: value.privacy });
+});
+
+onMounted(() => {
+	preventCloseUnsaved[props.modalUuid] = () =>
+		displayNameUnsaved.value.length + privacyUnsaved.value.length > 0;
+});
+
+onBeforeUnmount(() => {
+	delete preventCloseUnsaved[props.modalUuid];
+});
 </script>
 
 <template>
 	<div class="settings-tab section">
 		<div
 			v-if="
-				isEditable() &&
+				isEditable('playlists.update.displayName') &&
 				!(
 					playlist.type === 'user-liked' ||
 					playlist.type === 'user-disliked'
@@ -78,17 +150,17 @@ const updatePrivacy = () => {
 			<div class="control is-grouped input-with-button">
 				<p class="control is-expanded">
 					<input
-						v-model="playlist.displayName"
+						v-model="displayNameInputs['displayName'].value"
 						class="input"
 						type="text"
 						placeholder="Playlist Display Name"
-						@keyup.enter="renamePlaylist()"
+						@keyup.enter="saveDisplayName()"
 					/>
 				</p>
 				<p class="control">
 					<button
 						class="button is-info"
-						@click.prevent="renamePlaylist()"
+						@click.prevent="saveDisplayName()"
 					>
 						Rename
 					</button>
@@ -96,11 +168,11 @@ 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">
-					<select v-model="playlist.privacy">
+					<select v-model="privacyInputs['privacy'].value">
 						<option value="private">Private</option>
 						<option value="public">Public</option>
 					</select>
@@ -108,7 +180,7 @@ const updatePrivacy = () => {
 				<p class="control">
 					<button
 						class="button is-info"
-						@click.prevent="updatePrivacy()"
+						@click.prevent="savePrivacy()"
 					>
 						Update Privacy
 					</button>

+ 65 - 49
frontend/src/components/modals/EditPlaylist/index.vue

@@ -14,7 +14,6 @@ import { useEditPlaylistStore } from "@/stores/editPlaylist";
 import { useStationStore } from "@/stores/station";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useModalsStore } from "@/stores/modals";
-import ws from "@/ws";
 import utils from "@/utils";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
@@ -31,11 +30,12 @@ const QuickConfirm = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	playlistId: { type: String, required: true }
 });
 
 const { socket } = useWebsocketsStore();
-const editPlaylistStore = useEditPlaylistStore(props);
+const editPlaylistStore = useEditPlaylistStore({ modalUuid: props.modalUuid });
 const stationStore = useStationStore();
 const userAuthStore = useUserAuthStore();
 
@@ -55,7 +55,7 @@ const playlistSongs = computed({
 	}
 });
 
-const { playlistId, tab, playlist } = storeToRefs(editPlaylistStore);
+const { tab, playlist } = storeToRefs(editPlaylistStore);
 const { setPlaylist, clearPlaylist, addSong, removeSong, repositionedSong } =
 	editPlaylistStore;
 
@@ -66,27 +66,21 @@ const showTab = payload => {
 	editPlaylistStore.showTab(payload);
 };
 
-const isEditable = () =>
-	(playlist.value.type === "user" ||
-		playlist.value.type === "user-liked" ||
-		playlist.value.type === "user-disliked") &&
-	(userId.value === playlist.value.createdBy || userRole.value === "admin");
-
-const init = () => {
-	gettingSongs.value = true;
-	socket.dispatch("playlists.getPlaylist", playlistId.value, res => {
-		if (res.status === "success") {
-			setPlaylist(res.data.playlist);
-		} else new Toast(res.message);
-		gettingSongs.value = false;
-	});
-};
-
-const isAdmin = () => userRole.value === "admin";
+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" ||
+		playlist.value.type === "admin") &&
+		(isOwner() || hasPermission(permission))) ||
+	(playlist.value.type === "genre" &&
+		permission === "playlists.update.privacy" &&
+		hasPermission(permission));
+
 const repositionSong = ({ moved }) => {
 	const { oldIndex, newIndex } = moved;
 	if (oldIndex === newIndex) return; // we only need to update when song is moved
@@ -171,7 +165,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();
@@ -254,7 +248,15 @@ const clearAndRefillGenrePlaylist = () => {
 };
 
 onMounted(() => {
-	ws.onConnect(init);
+	socket.onConnect(() => {
+		gettingSongs.value = true;
+		socket.dispatch("playlists.getPlaylist", props.playlistId, res => {
+			if (res.status === "success") {
+				setPlaylist(res.data.playlist);
+			} else new Toast(res.message);
+			gettingSongs.value = false;
+		});
+	});
 
 	socket.on(
 		"event:playlist.song.added",
@@ -314,13 +316,15 @@ onBeforeUnmount(() => {
 <template>
 	<modal
 		:title="
-			userId === playlist.createdBy ? 'Edit Playlist' : 'View Playlist'
+			isEditable('playlists.update.privacy')
+				? `Edit ${playlist.type === 'admin' ? 'Admin ' : ''}Playlist`
+				: `View ${playlist.type === 'admin' ? 'Admin ' : ''}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>
@@ -338,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>
@@ -351,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>
@@ -362,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>
@@ -370,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>
@@ -394,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">
@@ -412,7 +408,9 @@ onBeforeUnmount(() => {
 							@start="drag = true"
 							@end="drag = false"
 							@update="repositionSong"
-							:disabled="!isEditable()"
+							:disabled="
+								!isEditable('playlists.songs.reposition')
+							"
 						>
 							<template #item="{ element, index }">
 								<song-item
@@ -450,7 +448,9 @@ onBeforeUnmount(() => {
 										<quick-confirm
 											v-if="
 												userId === playlist.createdBy ||
-												isEditable()
+												isEditable(
+													'playlists.songs.reposition'
+												)
 											"
 											placement="left"
 											@confirm="
@@ -468,7 +468,11 @@ onBeforeUnmount(() => {
 										</quick-confirm>
 										<i
 											class="material-icons"
-											v-if="isEditable() && index > 0"
+											v-if="
+												isEditable(
+													'playlists.songs.reposition'
+												) && index > 0
+											"
 											@click="moveSongToTop(index)"
 											content="Move to top of Playlist"
 											v-tippy
@@ -476,7 +480,9 @@ onBeforeUnmount(() => {
 										>
 										<i
 											v-if="
-												isEditable() &&
+												isEditable(
+													'playlists.songs.reposition'
+												) &&
 												playlistSongs.length - 1 !==
 													index
 											"
@@ -503,14 +509,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">
@@ -518,7 +531,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">
@@ -527,7 +543,7 @@ onBeforeUnmount(() => {
 				</quick-confirm>
 				<quick-confirm
 					v-if="
-						isEditable() &&
+						isEditable('playlists.removeAdmin') &&
 						!(
 							playlist.type === 'user-liked' ||
 							playlist.type === 'user-disliked'

+ 28 - 17
frontend/src/components/modals/EditSong/Tabs/Discogs.vue

@@ -9,7 +9,7 @@ import { useEditSongStore } from "@/stores/editSong";
 import { useWebsocketsStore } from "@/stores/websockets";
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" },
+	modalUuid: { type: String, required: true },
 	modalModulePath: {
 		type: String,
 		default: "modals/editSong/MODAL_UUID"
@@ -17,11 +17,11 @@ const props = defineProps({
 	bulk: { type: Boolean, default: false }
 });
 
-const editSongStore = useEditSongStore(props);
+const editSongStore = useEditSongStore({ modalUuid: props.modalUuid });
 
 const { socket } = useWebsocketsStore();
 
-const { song } = storeToRefs(editSongStore);
+const { form } = storeToRefs(editSongStore);
 
 const { selectDiscogsInfo } = editSongStore;
 
@@ -136,7 +136,7 @@ const selectTrack = (apiResultIndex, trackIndex) => {
 };
 
 onMounted(() => {
-	discogsQuery.value = song.value.title;
+	discogsQuery.value = form.value.inputs.title.value;
 
 	keyboardShortcuts.registerShortcut("editSong.focusDiscogs", {
 		keyCode: 35,
@@ -150,21 +150,25 @@ onMounted(() => {
 
 <template>
 	<div class="discogs-tab">
-		<div class="selected-discogs-info" v-if="!song.discogs">
-			<p class="selected-discogs-info-none">None</p>
-		</div>
-		<div class="selected-discogs-info" v-if="song.discogs">
+		<div
+			class="selected-discogs-info"
+			v-if="form.inputs.discogs.value && form.inputs.discogs.value.album"
+		>
 			<div class="top-container">
-				<img :src="song.discogs.album.albumArt" />
+				<img :src="form.inputs.discogs.value.album.albumArt" />
 				<div class="right-container">
 					<p class="album-title">
-						{{ song.discogs.album.title }}
+						{{ form.inputs.discogs.value.album.title }}
 					</p>
 					<div class="bottom-row">
 						<p class="type-year">
-							<span>{{ song.discogs.album.type }}</span>
+							<span>{{
+								form.inputs.discogs.value.album.type
+							}}</span>
-							<span>{{ song.discogs.album.year }}</span>
+							<span>{{
+								form.inputs.discogs.value.album.year
+							}}</span>
 						</p>
 					</div>
 				</div>
@@ -172,25 +176,32 @@ onMounted(() => {
 			<div class="bottom-container">
 				<p class="bottom-container-field">
 					Artists:
-					<span>{{ song.discogs.album.artists.join(", ") }}</span>
+					<span>{{
+						form.inputs.discogs.value.album.artists.join(", ")
+					}}</span>
 				</p>
 				<p class="bottom-container-field">
 					Genres:
-					<span>{{ song.discogs.album.genres.join(", ") }}</span>
+					<span>{{
+						form.inputs.discogs.value.album.genres.join(", ")
+					}}</span>
 				</p>
 				<p class="bottom-container-field">
 					Data quality:
-					<span>{{ song.discogs.dataQuality }}</span>
+					<span>{{ form.inputs.discogs.value.dataQuality }}</span>
 				</p>
 				<p class="bottom-container-field">
 					Track:
 					<span
-						>{{ song.discogs.track.position }}.
-						{{ song.discogs.track.title }}</span
+						>{{ form.inputs.discogs.value.track.position }}.
+						{{ form.inputs.discogs.value.track.title }}</span
 					>
 				</p>
 			</div>
 		</div>
+		<div class="selected-discogs-info" v-else>
+			<p class="selected-discogs-info-none">None</p>
+		</div>
 
 		<label class="label"> Search for a song from Discogs </label>
 		<div class="control is-grouped input-with-button">

+ 3 - 3
frontend/src/components/modals/EditSong/Tabs/Reports.vue

@@ -12,11 +12,11 @@ const ReportInfoItem = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" },
+	modalUuid: { type: String, required: true },
 	modalModulePath: { type: String, default: "modals/editSong/MODAL_UUID" }
 });
 
-const editSongStore = useEditSongStore(props);
+const editSongStore = useEditSongStore({ modalUuid: props.modalUuid });
 
 const { socket } = useWebsocketsStore();
 
@@ -50,7 +50,7 @@ const sortedByCategory = computed(() => {
 		})
 	);
 
-	return <any>categories;
+	return categories;
 });
 
 const { resolveReport } = editSongStore;

+ 3 - 7
frontend/src/components/modals/EditSong/Tabs/Songs.vue

@@ -1,8 +1,6 @@
 <script setup lang="ts">
 import { defineAsyncComponent, ref, onMounted } from "vue";
 
-import { storeToRefs } from "pinia";
-
 import { useEditSongStore } from "@/stores/editSong";
 
 import { useSearchMusare } from "@/composables/useSearchMusare";
@@ -12,15 +10,13 @@ const SongItem = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" },
+	modalUuid: { type: String, required: true },
 	modalModulePath: { type: String, default: "modals/editSong/MODAL_UUID" }
 });
 
 const sitename = ref("Musare");
 
-const editSongStore = useEditSongStore(props);
-
-const { song } = storeToRefs(editSongStore);
+const { form } = useEditSongStore({ modalUuid: props.modalUuid });
 
 const {
 	musareSearch,
@@ -32,7 +28,7 @@ const {
 onMounted(async () => {
 	sitename.value = await lofig.get("siteSettings.sitename");
 
-	musareSearch.value.query = song.value.title;
+	musareSearch.value.query = form.inputs.title.value;
 	searchForMusareSongs(1, false);
 });
 </script>

+ 10 - 9
frontend/src/components/modals/EditSong/Tabs/Youtube.vue

@@ -8,25 +8,26 @@ import { useSearchYoutube } from "@/composables/useSearchYoutube";
 import SearchQueryItem from "../../../SearchQueryItem.vue";
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" },
+	modalUuid: { type: String, required: true },
 	modalModulePath: { type: String, default: "modals/editSong/MODAL_UUID" }
 });
 
-const editSongStore = useEditSongStore(props);
+const editSongStore = useEditSongStore({ modalUuid: props.modalUuid });
 
-const { song, newSong } = storeToRefs(editSongStore);
+const { form, newSong } = storeToRefs(editSongStore);
 
-const { updateYoutubeId, updateTitle, updateThumbnail } = editSongStore;
+const { updateYoutubeId } = editSongStore;
 
 const { youtubeSearch, searchForSongs, loadMoreSongs } = useSearchYoutube();
 
 const selectSong = result => {
 	updateYoutubeId(result.id);
 
-	if (newSong) {
-		updateTitle(result.title);
-		updateThumbnail(result.thumbnail);
-	}
+	if (newSong)
+		form.value.setValue({
+			title: result.title,
+			thumbnail: result.thumbnail
+		});
 };
 </script>
 
@@ -66,7 +67,7 @@ const selectSong = result => {
 				<template #actions>
 					<i
 						class="material-icons icon-selected"
-						v-if="result.id === song.youtubeId"
+						v-if="result.id === form.inputs.youtubeId.value"
 						key="selected"
 						>radio_button_checked
 					</i>

Plik diff jest za duży
+ 350 - 587
frontend/src/components/modals/EditSong/index.vue


+ 260 - 136
frontend/src/components/modals/EditUser.vue

@@ -1,18 +1,11 @@
 <script setup lang="ts">
-import {
-	defineAsyncComponent,
-	ref,
-	watch,
-	onMounted,
-	onBeforeUnmount
-} from "vue";
+import { defineAsyncComponent, watch, onMounted, onBeforeUnmount } from "vue";
 import Toast from "toasters";
-import { storeToRefs } from "pinia";
-import ws from "@/ws";
 import validation from "@/validation";
-import { useEditUserStore } from "@/stores/editUser";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useForm } from "@/composables/useForm";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const QuickConfirm = defineAsyncComponent(
@@ -20,171 +13,275 @@ const QuickConfirm = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	userId: { type: String, required: true }
 });
 
-const editUserStore = useEditUserStore(props);
-
 const { socket } = useWebsocketsStore();
 
-const { userId, user } = storeToRefs(editUserStore);
-const { setUser } = editUserStore;
-
-const { closeCurrentModal } = useModalsStore();
-
-const ban = ref({ reason: "", expiresAt: "1h" });
-
-const init = () => {
-	if (userId.value)
-		socket.dispatch(`users.getUserFromId`, userId.value, res => {
-			if (res.status === "success") {
-				setUser(res.data);
-
-				socket.dispatch("apis.joinRoom", `edit-user.${userId.value}`);
-
-				socket.on(
-					"event:user.removed",
-					res => {
-						if (res.data.userId === userId.value)
-							closeCurrentModal();
-					},
-					{ modalUuid: props.modalUuid }
-				);
-			} else {
-				new Toast("User with that ID not found");
-				closeCurrentModal();
+const { closeCurrentModal, preventCloseUnsaved } = useModalsStore();
+
+const { hasPermission } = useUserAuthStore();
+
+const {
+	inputs: usernameInputs,
+	unsavedChanges: usernameUnsaved,
+	save: saveUsername,
+	setOriginalValue: setUsername
+} = useForm(
+	{
+		username: {
+			value: "",
+			validate: value => {
+				if (!validation.isLength(value, 2, 32))
+					return "Username must have between 2 and 32 characters.";
+				if (!validation.regex.custom("a-zA-Z0-9_-").test(value))
+					return "Invalid username format. Allowed characters: a-z, A-Z, 0-9, _ and -.";
+				return true;
 			}
-		});
-};
-
-const updateUsername = () => {
-	const { username } = user.value;
-
-	if (!validation.isLength(username, 2, 32))
-		return new Toast("Username must have between 2 and 32 characters.");
-
-	if (!validation.regex.custom("a-zA-Z0-9_-").test(username))
-		return new Toast(
-			"Invalid username format. Allowed characters: a-z, A-Z, 0-9, _ and -."
-		);
-
-	return socket.dispatch(
-		`users.updateUsername`,
-		user.value._id,
-		username,
-		res => {
-			new Toast(res.message);
 		}
-	);
-};
-
-const updateEmail = () => {
-	const email = user.value.email.address;
-
-	if (!validation.isLength(email, 3, 254))
-		return new Toast("Email must have between 3 and 254 characters.");
-
-	if (
-		email.indexOf("@") !== email.lastIndexOf("@") ||
-		!validation.regex.emailSimple.test(email) ||
-		!validation.regex.ascii.test(email)
-	)
-		return new Toast("Invalid email format.");
-
-	return socket.dispatch(`users.updateEmail`, user.value._id, email, res => {
-		new Toast(res.message);
-	});
-};
-
-const updateRole = () => {
-	socket.dispatch(
-		`users.updateRole`,
-		user.value._id,
-		user.value.role,
-		res => {
-			new Toast(res.message);
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success")
+			socket.dispatch(
+				"users.updateUsername",
+				props.userId,
+				values.username,
+				res => {
+					if (res.status === "success") {
+						resolve();
+						new Toast(res.message);
+					} else reject(new Error(res.message));
+				}
+			);
+		else {
+			Object.values(messages).forEach(message => {
+				new Toast({ content: message, timeout: 8000 });
+			});
+			resolve();
 		}
-	);
-};
-
-const banUser = () => {
-	const { reason } = ban.value;
+	},
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
 
-	if (!validation.isLength(reason, 1, 64))
-		return new Toast("Reason must have between 1 and 64 characters.");
+const {
+	inputs: emailInputs,
+	unsavedChanges: emailUnsaved,
+	save: saveEmail,
+	setOriginalValue: setEmail
+} = useForm(
+	{
+		email: {
+			value: "",
+			validate: value => {
+				if (!validation.isLength(value, 3, 254))
+					return "Email must have between 3 and 254 characters.";
+				if (
+					value.indexOf("@") !== value.lastIndexOf("@") ||
+					!validation.regex.emailSimple.test(value) ||
+					!validation.regex.ascii.test(value)
+				)
+					return "Invalid email format.";
+				return true;
+			}
+		}
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success")
+			socket.dispatch(
+				"users.updateEmail",
+				props.userId,
+				values.email,
+				res => {
+					if (res.status === "success") {
+						resolve();
+						new Toast(res.message);
+					} else reject(new Error(res.message));
+				}
+			);
+		else {
+			Object.values(messages).forEach(message => {
+				new Toast({ content: message, timeout: 8000 });
+			});
+			resolve();
+		}
+	},
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
 
-	if (!validation.regex.ascii.test(reason))
-		return new Toast(
-			"Invalid reason format. Only ascii characters are allowed."
-		);
+const {
+	inputs: roleInputs,
+	unsavedChanges: roleUnsaved,
+	save: saveRole,
+	setOriginalValue: setRole
+} = useForm(
+	{ role: "" },
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success")
+			socket.dispatch(
+				"users.updateRole",
+				props.userId,
+				values.role,
+				res => {
+					if (res.status === "success") {
+						resolve();
+						new Toast(res.message);
+					} else reject(new Error(res.message));
+				}
+			);
+		else {
+			Object.values(messages).forEach(message => {
+				new Toast({ content: message, timeout: 8000 });
+			});
+			resolve();
+		}
+	},
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
 
-	return socket.dispatch(
-		`users.banUserById`,
-		user.value._id,
-		ban.value.reason,
-		ban.value.expiresAt,
-		res => {
-			new Toast(res.message);
+const {
+	inputs: banInputs,
+	unsavedChanges: banUnsaved,
+	save: saveBan
+} = useForm(
+	{
+		reason: {
+			value: "",
+			validate: value => {
+				if (!validation.isLength(value, 1, 64))
+					return "Reason must have between 1 and 64 characters.";
+				if (!validation.regex.ascii.test(value))
+					return "Invalid reason format. Only ascii characters are allowed.";
+				return true;
+			}
+		},
+		expiresAt: "1h"
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success")
+			socket.dispatch(
+				"users.banUserById",
+				props.userId,
+				values.reason,
+				values.expiresAt,
+				res => {
+					new Toast(res.message);
+					if (res.status === "success") resolve();
+					else reject(new Error(res.message));
+				}
+			);
+		else {
+			Object.values(messages).forEach(message => {
+				new Toast({ content: message, timeout: 8000 });
+			});
+			resolve();
 		}
-	);
-};
+	},
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
 
 const resendVerificationEmail = () => {
-	socket.dispatch(`users.resendVerifyEmail`, user.value._id, res => {
+	socket.dispatch(`users.resendVerifyEmail`, props.userId, res => {
 		new Toast(res.message);
 	});
 };
 
 const requestPasswordReset = () => {
-	socket.dispatch(`users.adminRequestPasswordReset`, user.value._id, res => {
+	socket.dispatch(`users.adminRequestPasswordReset`, props.userId, res => {
 		new Toast(res.message);
 	});
 };
 
 const removeAccount = () => {
-	socket.dispatch(`users.adminRemove`, user.value._id, res => {
+	socket.dispatch(`users.adminRemove`, props.userId, res => {
 		new Toast(res.message);
 	});
 };
 
 const removeSessions = () => {
-	socket.dispatch(`users.removeSessions`, user.value._id, res => {
+	socket.dispatch(`users.removeSessions`, props.userId, res => {
 		new Toast(res.message);
 	});
 };
 
-// When the userId changes, run init. There can be a delay between the modal opening and the required data (userId) being available
-watch(userId, () => init());
+watch(
+	() => hasPermission("users.get") && hasPermission("users.update"),
+	value => {
+		if (!value) closeCurrentModal(true);
+	}
+);
 
 onMounted(() => {
-	ws.onConnect(init);
+	preventCloseUnsaved[props.modalUuid] = () =>
+		usernameUnsaved.value.length +
+			emailUnsaved.value.length +
+			roleUnsaved.value.length +
+			banUnsaved.value.length >
+		0;
+
+	socket.onConnect(() => {
+		socket.dispatch(`users.getUserFromId`, props.userId, res => {
+			if (res.status === "success") {
+				setUsername({ username: res.data.username });
+				setEmail({ email: res.data.email.address });
+				setRole({ role: res.data.role });
+
+				socket.dispatch("apis.joinRoom", `edit-user.${props.userId}`);
+			} else {
+				new Toast("User with that ID not found");
+				closeCurrentModal();
+			}
+		});
+	});
+
+	socket.on(
+		"event:user.removed",
+		res => {
+			if (res.data.userId === props.userId) closeCurrentModal(true);
+		},
+		{ modalUuid: props.modalUuid }
+	);
 });
 
 onBeforeUnmount(() => {
-	socket.dispatch("apis.leaveRoom", `edit-user.${userId.value}`, () => {});
-	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
-	editUserStore.$dispose();
+	delete preventCloseUnsaved[props.modalUuid];
+	socket.dispatch("apis.leaveRoom", `edit-user.${props.userId}`, () => {});
 });
 </script>
 
 <template>
 	<div>
 		<modal title="Edit User">
-			<template #body v-if="user && user._id">
+			<template #body>
 				<div class="section">
 					<label class="label"> Change username </label>
 					<p class="control is-grouped">
 						<span class="control is-expanded">
 							<input
-								v-model="user.username"
+								v-model="usernameInputs['username'].value"
 								class="input"
 								type="text"
 								placeholder="Username"
 								autofocus
 							/>
 						</span>
-						<span class="control">
-							<a class="button is-info" @click="updateUsername()"
+						<span
+							v-if="hasPermission('users.update')"
+							class="control"
+						>
+							<a class="button is-info" @click="saveUsername()"
 								>Update Username</a
 							>
 						</span>
@@ -194,15 +291,21 @@ onBeforeUnmount(() => {
 					<p class="control is-grouped">
 						<span class="control is-expanded">
 							<input
-								v-model="user.email.address"
+								v-model="emailInputs['email'].value"
 								class="input"
 								type="text"
 								placeholder="Email Address"
 								autofocus
+								:disabled="
+									!hasPermission('users.update.restricted')
+								"
 							/>
 						</span>
-						<span class="control">
-							<a class="button is-info" @click="updateEmail()"
+						<span
+							v-if="hasPermission('users.update.restricted')"
+							class="control"
+						>
+							<a class="button is-info" @click="saveEmail()"
 								>Update Email Address</a
 							>
 						</span>
@@ -211,24 +314,33 @@ onBeforeUnmount(() => {
 					<label class="label"> Change user role </label>
 					<div class="control is-grouped">
 						<div class="control is-expanded select">
-							<select v-model="user.role">
-								<option>default</option>
+							<select
+								v-model="roleInputs['role'].value"
+								:disabled="
+									!hasPermission('users.update.restricted')
+								"
+							>
+								<option>user</option>
+								<option>moderator</option>
 								<option>admin</option>
 							</select>
 						</div>
-						<p class="control">
-							<a class="button is-info" @click="updateRole()"
+						<p
+							v-if="hasPermission('users.update.restricted')"
+							class="control"
+						>
+							<a class="button is-info" @click="saveRole()"
 								>Update Role</a
 							>
 						</p>
 					</div>
 				</div>
 
-				<div class="section">
+				<div v-if="hasPermission('users.ban')" class="section">
 					<label class="label"> Punish/Ban User </label>
 					<p class="control is-grouped">
 						<span class="control select">
-							<select v-model="ban.expiresAt">
+							<select v-model="banInputs['expiresAt'].value">
 								<option value="1h">1 Hour</option>
 								<option value="12h">12 Hours</option>
 								<option value="1d">1 Day</option>
@@ -241,7 +353,7 @@ onBeforeUnmount(() => {
 						</span>
 						<span class="control is-expanded">
 							<input
-								v-model="ban.reason"
+								v-model="banInputs['reason'].value"
 								class="input"
 								type="text"
 								placeholder="Ban reason"
@@ -249,7 +361,7 @@ onBeforeUnmount(() => {
 							/>
 						</span>
 						<span class="control">
-							<a class="button is-danger" @click="banUser()">
+							<a class="button is-danger" @click="saveBan()">
 								Ban user
 							</a>
 						</span>
@@ -257,16 +369,28 @@ onBeforeUnmount(() => {
 				</div>
 			</template>
 			<template #footer>
-				<quick-confirm @confirm="resendVerificationEmail()">
+				<quick-confirm
+					v-if="hasPermission('users.resendVerifyEmail')"
+					@confirm="resendVerificationEmail()"
+				>
 					<a class="button is-warning"> Resend verification email </a>
 				</quick-confirm>
-				<quick-confirm @confirm="requestPasswordReset()">
+				<quick-confirm
+					v-if="hasPermission('users.requestPasswordReset')"
+					@confirm="requestPasswordReset()"
+				>
 					<a class="button is-warning"> Request password reset </a>
 				</quick-confirm>
-				<quick-confirm @confirm="removeSessions()">
+				<quick-confirm
+					v-if="hasPermission('users.remove.sessions')"
+					@confirm="removeSessions()"
+				>
 					<a class="button is-warning"> Remove all sessions </a>
 				</quick-confirm>
-				<quick-confirm @confirm="removeAccount()">
+				<quick-confirm
+					v-if="hasPermission('users.remove')"
+					@confirm="removeAccount()"
+				>
 					<a class="button is-danger"> Remove account </a>
 				</quick-confirm>
 			</template>

+ 47 - 22
frontend/src/components/modals/ImportAlbum.vue

@@ -12,7 +12,6 @@ import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import { useImportAlbumStore } from "@/stores/importAlbum";
-import ws from "@/ws";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const SongItem = defineAsyncComponent(
@@ -20,12 +19,13 @@ const SongItem = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	songs: { type: Array, default: () => [] }
 });
 
 const { socket } = useWebsocketsStore();
 
-const importAlbumStore = useImportAlbumStore(props);
+const importAlbumStore = useImportAlbumStore({ modalUuid: props.modalUuid });
 const { discogsTab, discogsAlbum, prefillDiscogs, playlistSongs } =
 	storeToRefs(importAlbumStore);
 const {
@@ -37,7 +37,7 @@ const {
 	updatePlaylistSong
 } = importAlbumStore;
 
-const { openModal } = useModalsStore();
+const { openModal, preventCloseCbs } = useModalsStore();
 
 const isImportingPlaylist = ref(false);
 const trackSongs = ref([]);
@@ -72,10 +72,6 @@ const showDiscogsTab = tab => {
 	return importAlbumStore.showDiscogsTab(tab);
 };
 
-const init = () => {
-	socket.dispatch("apis.joinRoom", "import-album");
-};
-
 const startEditingSongs = () => {
 	songsToEdit.value = [];
 	trackSongs.value.forEach((songs, index) => {
@@ -86,18 +82,16 @@ const startEditingSongs = () => {
 			delete album.expanded;
 			delete album.gotMoreInfo;
 
-			const songToEdit = <
-				{
-					youtubeId: string;
-					prefill: {
-						discogs: typeof album;
-						title?: string;
-						thumbnail?: string;
-						genres?: string[];
-						artists?: string[];
-					};
-				}
-			>{
+			const songToEdit: {
+				youtubeId: string;
+				prefill: {
+					discogs: typeof album;
+					title?: string;
+					thumbnail?: string;
+					genres?: string[];
+					artists?: string[];
+				};
+			} = {
 				youtubeId: song.youtubeId,
 				prefill: {
 					discogs: album
@@ -124,7 +118,7 @@ const startEditingSongs = () => {
 	else {
 		openModal({
 			modal: "editSong",
-			data: { songs: songsToEdit.value }
+			props: { songs: songsToEdit.value }
 		});
 	}
 };
@@ -332,7 +326,37 @@ const updateTrackSong = updatedSong => {
 };
 
 onMounted(() => {
-	ws.onConnect(init);
+	setPlaylistSongs(props.songs);
+
+	preventCloseCbs[props.modalUuid] = (): Promise<void> =>
+		new Promise(resolve => {
+			const confirmReasons = [];
+
+			let unverifiedSongs = 0;
+			trackSongs.value.forEach(songs => {
+				songs.forEach(song => {
+					if (!song.verified) unverifiedSongs += 1;
+				});
+			});
+			if (unverifiedSongs > 0)
+				confirmReasons.push(
+					`There are still ${unverifiedSongs} unverified songs. Are you sure you want to close Import Album?`
+				);
+
+			if (confirmReasons.length > 0)
+				openModal({
+					modal: "confirm",
+					props: {
+						message: confirmReasons,
+						onCompleted: resolve
+					}
+				});
+			else resolve();
+		});
+
+	socket.onConnect(() => {
+		socket.dispatch("apis.joinRoom", "import-album");
+	});
 
 	socket.on("event:admin.song.updated", res => {
 		updateTrackSong(res.data.song);
@@ -340,6 +364,7 @@ onMounted(() => {
 });
 
 onBeforeUnmount(() => {
+	delete preventCloseCbs[props.modalUuid];
 	selectDiscogsAlbum({});
 	setPlaylistSongs([]);
 	showDiscogsTab("search");

+ 131 - 111
frontend/src/components/modals/ManageStation/Settings.vue

@@ -1,123 +1,133 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref, onMounted } from "vue";
+import { defineAsyncComponent, watch } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import validation from "@/validation";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useManageStationStore } from "@/stores/manageStation";
+import { useForm } from "@/composables/useForm";
 
 const InfoIcon = defineAsyncComponent(
 	() => import("@/components/InfoIcon.vue")
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true }
 });
 
 const { socket } = useWebsocketsStore();
 
-const manageStationStore = useManageStationStore(props);
+const manageStationStore = useManageStationStore({
+	modalUuid: props.modalUuid
+});
 const { station } = storeToRefs(manageStationStore);
 const { editStation } = manageStationStore;
 
-const localStation = ref({
-	name: "",
-	displayName: "",
-	description: "",
-	theme: "blue",
-	privacy: "private",
-	requests: {
-		enabled: true,
-		access: "owner",
-		limit: 3
-	},
-	autofill: {
-		enabled: true,
-		limit: 30,
-		mode: "random"
-	}
-});
-
-const update = () => {
-	if (
-		JSON.stringify({
-			name: localStation.value.name,
-			displayName: localStation.value.displayName,
-			description: localStation.value.description,
-			theme: localStation.value.theme,
-			privacy: localStation.value.privacy,
-			requests: {
-				enabled: localStation.value.requests.enabled,
-				access: localStation.value.requests.access,
-				limit: localStation.value.requests.limit
-			},
-			autofill: {
-				enabled: localStation.value.autofill.enabled,
-				limit: localStation.value.autofill.limit,
-				mode: localStation.value.autofill.mode
+const { inputs, save, setOriginalValue } = useForm(
+	{
+		name: {
+			value: station.value.name,
+			validate: value => {
+				if (!validation.isLength(value, 2, 16))
+					return "Name must have between 2 and 16 characters.";
+				if (!validation.regex.az09_.test(value))
+					return "Invalid name format. Allowed characters: a-z, 0-9 and _.";
+				return true;
 			}
-		}) !==
-		JSON.stringify({
-			name: station.value.name,
-			displayName: station.value.displayName,
-			description: station.value.description,
-			theme: station.value.theme,
-			privacy: station.value.privacy,
-			requests: {
-				enabled: station.value.requests.enabled,
-				access: station.value.requests.access,
-				limit: station.value.requests.limit
-			},
-			autofill: {
-				enabled: station.value.autofill.enabled,
-				limit: station.value.autofill.limit,
-				mode: station.value.autofill.mode
+		},
+		displayName: {
+			value: station.value.displayName,
+			validate: value => {
+				if (!validation.isLength(value, 2, 32))
+					return "Display name must have between 2 and 32 characters.";
+				if (!validation.regex.ascii.test(value))
+					return "Invalid display name format. Only ASCII characters are allowed.";
+				return true;
 			}
-		})
-	) {
-		const { name, displayName, description } = localStation.value;
-
-		if (!validation.isLength(name, 2, 16))
-			new Toast("Name must have between 2 and 16 characters.");
-		else if (!validation.regex.az09_.test(name))
-			new Toast(
-				"Invalid name format. Allowed characters: a-z, 0-9 and _."
-			);
-		else if (!validation.isLength(displayName, 2, 32))
-			new Toast("Display name must have between 2 and 32 characters.");
-		else if (!validation.regex.ascii.test(displayName))
-			new Toast(
-				"Invalid display name format. Only ASCII characters are allowed."
-			);
-		else if (!validation.isLength(description, 2, 200))
-			new Toast("Description must have between 2 and 200 characters.");
-		else if (
-			description
-				.split("")
-				.filter(character => character.charCodeAt(0) === 21328)
-				.length !== 0
-		)
-			new Toast("Invalid description format.");
-		else
+		},
+		description: {
+			value: station.value.description,
+			validate: value => {
+				if (
+					value
+						.split("")
+						.filter(character => character.charCodeAt(0) === 21328)
+						.length !== 0
+				)
+					return "Invalid description format.";
+				return true;
+			}
+		},
+		theme: station.value.theme,
+		privacy: station.value.privacy,
+		requestsEnabled: station.value.requests.enabled,
+		requestsAccess: station.value.requests.access,
+		requestsLimit: station.value.requests.limit,
+		autofillEnabled: station.value.autofill.enabled,
+		autofillLimit: station.value.autofill.limit,
+		autofillMode: station.value.autofill.mode
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success") {
+			const oldStation = JSON.parse(JSON.stringify(station.value));
+			const updatedStation = {
+				...oldStation,
+				name: values.name,
+				displayName: values.displayName,
+				description: values.description,
+				theme: values.theme,
+				privacy: values.privacy,
+				requests: {
+					...oldStation.requests,
+					enabled: values.requestsEnabled,
+					access: values.requestsAccess,
+					limit: values.requestsLimit
+				},
+				autofill: {
+					...oldStation.autofill,
+					enabled: values.autofillEnabled,
+					limit: values.autofillLimit,
+					mode: values.autofillMode
+				}
+			};
 			socket.dispatch(
 				"stations.update",
 				station.value._id,
-				localStation.value,
+				updatedStation,
 				res => {
 					new Toast(res.message);
-
 					if (res.status === "success") {
-						editStation(localStation.value);
-					}
+						editStation(updatedStation);
+						resolve();
+					} else reject(new Error(res.message));
 				}
 			);
-	} else {
-		new Toast("Please make a change before saving.");
+		} else {
+			Object.values(messages).forEach(message => {
+				new Toast({ content: message, timeout: 8000 });
+			});
+			resolve();
+		}
+	},
+	{
+		modalUuid: props.modalUuid
 	}
-};
+);
 
-onMounted(() => {
-	localStation.value = JSON.parse(JSON.stringify(station.value));
+watch(station, value => {
+	setOriginalValue({
+		name: value.name,
+		displayName: value.displayName,
+		description: value.description,
+		theme: value.theme,
+		privacy: value.privacy,
+		requestsEnabled: value.requests.enabled,
+		requestsAccess: value.requests.access,
+		requestsLimit: value.requests.limit,
+		autofillEnabled: value.autofill.enabled,
+		autofillLimit: value.autofill.limit,
+		autofillMode: value.autofill.mode
+	});
 });
 </script>
 
@@ -125,7 +135,7 @@ onMounted(() => {
 	<div class="station-settings">
 		<label class="label">Name</label>
 		<div class="control is-expanded">
-			<input class="input" type="text" v-model="localStation.name" />
+			<input class="input" type="text" v-model="inputs['name'].value" />
 		</div>
 
 		<label class="label">Display Name</label>
@@ -133,7 +143,7 @@ onMounted(() => {
 			<input
 				class="input"
 				type="text"
-				v-model="localStation.displayName"
+				v-model="inputs['displayName'].value"
 			/>
 		</div>
 
@@ -142,7 +152,7 @@ onMounted(() => {
 			<input
 				class="input"
 				type="text"
-				v-model="localStation.description"
+				v-model="inputs['description'].value"
 			/>
 		</div>
 
@@ -150,7 +160,7 @@ onMounted(() => {
 			<div class="small-section">
 				<label class="label">Theme</label>
 				<div class="control is-expanded select">
-					<select v-model="localStation.theme">
+					<select v-model="inputs['theme'].value">
 						<option value="blue" selected>Blue</option>
 						<option value="purple">Purple</option>
 						<option value="teal">Teal</option>
@@ -163,7 +173,7 @@ onMounted(() => {
 			<div class="small-section">
 				<label class="label">Privacy</label>
 				<div class="control is-expanded select">
-					<select v-model="localStation.privacy">
+					<select v-model="inputs['privacy'].value">
 						<option value="public">Public</option>
 						<option value="unlisted">Unlisted</option>
 						<option value="private" selected>Private</option>
@@ -172,9 +182,8 @@ onMounted(() => {
 			</div>
 
 			<div
-				v-if="localStation.requests"
 				class="requests-settings"
-				:class="{ enabled: localStation.requests.enabled }"
+				:class="{ enabled: inputs['requestsEnabled'].value }"
 			>
 				<div class="toggle-row">
 					<label class="label">
@@ -188,7 +197,7 @@ onMounted(() => {
 							<input
 								type="checkbox"
 								id="toggle-requests"
-								v-model="localStation.requests.enabled"
+								v-model="inputs['requestsEnabled'].value"
 							/>
 							<span class="slider round"></span>
 						</label>
@@ -196,7 +205,7 @@ onMounted(() => {
 						<label for="toggle-requests">
 							<p>
 								{{
-									localStation.requests.enabled
+									inputs["requestsEnabled"].value
 										? "Enabled"
 										: "Disabled"
 								}}
@@ -205,17 +214,23 @@ onMounted(() => {
 					</p>
 				</div>
 
-				<div v-if="localStation.requests.enabled" class="small-section">
+				<div
+					v-if="inputs['requestsEnabled'].value"
+					class="small-section"
+				>
 					<label class="label">Minimum access</label>
 					<div class="control is-expanded select">
-						<select v-model="localStation.requests.access">
+						<select v-model="inputs['requestsAccess'].value">
 							<option value="owner" selected>Owner</option>
 							<option value="user">User</option>
 						</select>
 					</div>
 				</div>
 
-				<div v-if="localStation.requests.enabled" class="small-section">
+				<div
+					v-if="inputs['requestsEnabled'].value"
+					class="small-section"
+				>
 					<label class="label">Per user request limit</label>
 					<div class="control is-expanded">
 						<input
@@ -223,16 +238,15 @@ onMounted(() => {
 							type="number"
 							min="1"
 							max="50"
-							v-model="localStation.requests.limit"
+							v-model="inputs['requestsLimit'].value"
 						/>
 					</div>
 				</div>
 			</div>
 
 			<div
-				v-if="localStation.autofill"
 				class="autofill-settings"
-				:class="{ enabled: localStation.autofill.enabled }"
+				:class="{ enabled: inputs['autofillEnabled'].value }"
 			>
 				<div class="toggle-row">
 					<label class="label">
@@ -246,7 +260,7 @@ onMounted(() => {
 							<input
 								type="checkbox"
 								id="toggle-autofill"
-								v-model="localStation.autofill.enabled"
+								v-model="inputs['autofillEnabled'].value"
 							/>
 							<span class="slider round"></span>
 						</label>
@@ -254,7 +268,7 @@ onMounted(() => {
 						<label for="toggle-autofill">
 							<p>
 								{{
-									localStation.autofill.enabled
+									inputs["autofillEnabled"].value
 										? "Enabled"
 										: "Disabled"
 								}}
@@ -263,7 +277,10 @@ onMounted(() => {
 					</p>
 				</div>
 
-				<div v-if="localStation.autofill.enabled" class="small-section">
+				<div
+					v-if="inputs['autofillEnabled'].value"
+					class="small-section"
+				>
 					<label class="label">Song limit</label>
 					<div class="control is-expanded">
 						<input
@@ -271,15 +288,18 @@ onMounted(() => {
 							type="number"
 							min="1"
 							max="50"
-							v-model="localStation.autofill.limit"
+							v-model="inputs['autofillLimit'].value"
 						/>
 					</div>
 				</div>
 
-				<div v-if="localStation.autofill.enabled" class="small-section">
+				<div
+					v-if="inputs['autofillEnabled'].value"
+					class="small-section"
+				>
 					<label class="label">Play mode</label>
 					<div class="control is-expanded select">
-						<select v-model="localStation.autofill.mode">
+						<select v-model="inputs['autofillMode'].value">
 							<option value="random" selected>Random</option>
 							<option value="sequential">Sequential</option>
 						</select>
@@ -288,7 +308,7 @@ onMounted(() => {
 			</div>
 		</div>
 
-		<button class="control is-expanded button is-primary" @click="update()">
+		<button class="control is-expanded button is-primary" @click="save()">
 			Save Changes
 		</button>
 	</div>

+ 337 - 251
frontend/src/components/modals/ManageStation/index.vue

@@ -31,17 +31,21 @@ const QuickConfirm = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	stationId: { type: String, required: true },
+	sector: { type: String, default: "admin" }
 });
 
 const tabs = ref([]);
 
 const userAuthStore = useUserAuthStore();
-const { loggedIn, userId, role } = storeToRefs(userAuthStore);
+const { loggedIn, userId } = storeToRefs(userAuthStore);
 
 const { socket } = useWebsocketsStore();
 
-const manageStationStore = useManageStationStore(props);
+const manageStationStore = useManageStationStore({
+	modalUuid: props.modalUuid
+});
 const {
 	stationId,
 	sector,
@@ -64,7 +68,11 @@ const {
 	updateStationPaused,
 	updateCurrentSong,
 	updateStation,
-	updateIsFavorited
+	updateIsFavorited,
+	hasPermission,
+	addDj,
+	removeDj,
+	updatePermissions
 } = manageStationStore;
 
 const { closeCurrentModal } = useModalsStore();
@@ -75,20 +83,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 => {
@@ -107,275 +109,338 @@ const resetQueue = () => {
 	});
 };
 
+const findTabOrClose = () => {
+	if (hasPermission("stations.update")) return showTab("settings");
+	if (canRequest()) return showTab("request");
+	if (hasPermission("stations.autofill")) return showTab("autofill");
+	if (hasPermission("stations.blacklist")) return showTab("blacklist");
+	if (
+		!(
+			sector.value === "home" &&
+			(hasPermission("stations.view") ||
+				station.value.privacy === "public")
+		)
+	)
+		return closeCurrentModal(true);
+	return null;
+};
+
 watch(
-	() => station.value.requests,
-	() => {
-		if (tab.value === "request" && !canRequest()) {
-			if (isOwnerOrAdmin()) showTab("settings");
-			else if (!(sector.value === "home" && !isOwnerOrAdmin()))
-				closeCurrentModal();
-		}
+	() => hasPermission("stations.update"),
+	value => {
+		if (!value && tab.value === "settings") findTabOrClose();
+	}
+);
+watch(
+	() => hasPermission("stations.request") && station.value.requests.enabled,
+	value => {
+		if (!value && tab.value === "request") findTabOrClose();
+	}
+);
+watch(
+	() => hasPermission("stations.autofill") && station.value.autofill.enabled,
+	value => {
+		if (!value && tab.value === "autofill") findTabOrClose();
 	}
 );
 watch(
-	() => station.value.autofill,
+	() => hasPermission("stations.blacklist"),
 	value => {
-		if (tab.value === "autofill" && value && !value.enabled)
-			showTab("settings");
+		if (!value && tab.value === "blacklist") findTabOrClose();
 	}
 );
 
 onMounted(() => {
-	socket.dispatch(`stations.getStationById`, stationId.value, res => {
-		if (res.status === "success") {
-			editStation(res.data.station);
+	manageStationStore.init({
+		stationId: props.stationId,
+		sector: props.sector
+	});
 
-			if (!isOwnerOrAdmin()) showTab("request");
+	socket.onConnect(() => {
+		socket.dispatch(
+			`stations.getStationById`,
+			stationId.value,
+			async res => {
+				if (res.status === "success") {
+					editStation(res.data.station);
 
-			const currentSong = res.data.station.currentSong
-				? res.data.station.currentSong
-				: {};
+					await updatePermissions();
 
-			updateCurrentSong(currentSong);
+					findTabOrClose();
 
-			updateStationPaused(res.data.station.paused);
+					const currentSong = res.data.station.currentSong
+						? res.data.station.currentSong
+						: {};
 
-			socket.dispatch(
-				"stations.getStationAutofillPlaylistsById",
-				stationId.value,
-				res => {
-					if (res.status === "success")
-						setAutofillPlaylists(res.data.playlists);
-				}
-			);
+					updateCurrentSong(currentSong);
 
-			socket.dispatch(
-				"stations.getStationBlacklistById",
-				stationId.value,
-				res => {
-					if (res.status === "success")
-						setBlacklist(res.data.playlists);
-				}
-			);
+					updateStationPaused(res.data.station.paused);
 
-			if (isOwnerOrAdmin()) {
-				socket.dispatch(
-					"playlists.getPlaylistForStation",
-					stationId.value,
-					true,
-					res => {
-						if (res.status === "success") {
-							updateStationPlaylist(res.data.playlist);
+					socket.dispatch(
+						"stations.getStationAutofillPlaylistsById",
+						stationId.value,
+						res => {
+							if (res.status === "success")
+								setAutofillPlaylists(res.data.playlists);
 						}
+					);
+
+					socket.dispatch(
+						"stations.getStationBlacklistById",
+						stationId.value,
+						res => {
+							if (res.status === "success")
+								setBlacklist(res.data.playlists);
+						}
+					);
+
+					if (hasPermission("stations.view")) {
+						socket.dispatch(
+							"playlists.getPlaylistForStation",
+							stationId.value,
+							true,
+							res => {
+								if (res.status === "success") {
+									updateStationPlaylist(res.data.playlist);
+								}
+							}
+						);
 					}
-				);
-			}
-
-			socket.dispatch("stations.getQueue", stationId.value, res => {
-				if (res.status === "success") updateSongsList(res.data.queue);
-			});
-
-			socket.dispatch(
-				"apis.joinRoom",
-				`manage-station.${stationId.value}`
-			);
-
-			socket.on(
-				"event:station.updated",
-				res => {
-					updateStation(res.data.station);
-				},
-				{ modalUuid: props.modalUuid }
-			);
-
-			socket.on(
-				"event:station.autofillPlaylist",
-				res => {
-					const { playlist } = res.data;
-					const playlistIndex = autofill.value
-						.map(autofillPlaylist => autofillPlaylist._id)
-						.indexOf(playlist._id);
-					if (playlistIndex === -1) autofill.value.push(playlist);
-				},
-				{ modalUuid: props.modalUuid }
-			);
 
-			socket.on(
-				"event:station.blacklistedPlaylist",
-				res => {
-					const { playlist } = res.data;
-					const playlistIndex = blacklist.value
-						.map(blacklistedPlaylist => blacklistedPlaylist._id)
-						.indexOf(playlist._id);
-					if (playlistIndex === -1) blacklist.value.push(playlist);
-				},
-				{ modalUuid: props.modalUuid }
-			);
+					socket.dispatch(
+						"stations.getQueue",
+						stationId.value,
+						res => {
+							if (res.status === "success")
+								updateSongsList(res.data.queue);
+						}
+					);
+
+					socket.dispatch(
+						"apis.joinRoom",
+						`manage-station.${stationId.value}`
+					);
+				} else {
+					new Toast(`Station with that ID not found`);
+					closeCurrentModal();
+				}
+			}
+		);
 
-			socket.on(
-				"event:station.removedAutofillPlaylist",
-				res => {
-					const { playlistId } = res.data;
-					const playlistIndex = autofill.value
-						.map(playlist => playlist._id)
-						.indexOf(playlistId);
-					if (playlistIndex >= 0)
-						autofill.value.splice(playlistIndex, 1);
-				},
-				{ modalUuid: props.modalUuid }
-			);
+		socket.on(
+			"event:station.updated",
+			res => {
+				updateStation(res.data.station);
+			},
+			{ modalUuid: props.modalUuid }
+		);
 
-			socket.on(
-				"event:station.removedBlacklistedPlaylist",
-				res => {
-					const { playlistId } = res.data;
-					const playlistIndex = blacklist.value
-						.map(playlist => playlist._id)
-						.indexOf(playlistId);
-					if (playlistIndex >= 0)
-						blacklist.value.splice(playlistIndex, 1);
-				},
-				{ modalUuid: props.modalUuid }
-			);
+		socket.on(
+			"event:station.autofillPlaylist",
+			res => {
+				const { playlist } = res.data;
+				const playlistIndex = autofill.value
+					.map(autofillPlaylist => autofillPlaylist._id)
+					.indexOf(playlist._id);
+				if (playlistIndex === -1) autofill.value.push(playlist);
+			},
+			{ modalUuid: props.modalUuid }
+		);
 
-			socket.on(
-				"event:station.deleted",
-				() => {
-					new Toast(`The station you were editing was deleted.`);
-					closeCurrentModal();
-				},
-				{ modalUuid: props.modalUuid }
-			);
+		socket.on(
+			"event:station.blacklistedPlaylist",
+			res => {
+				const { playlist } = res.data;
+				const playlistIndex = blacklist.value
+					.map(blacklistedPlaylist => blacklistedPlaylist._id)
+					.indexOf(playlist._id);
+				if (playlistIndex === -1) blacklist.value.push(playlist);
+			},
+			{ modalUuid: props.modalUuid }
+		);
 
-			socket.on(
-				"event:user.station.favorited",
-				res => {
-					if (res.data.stationId === stationId.value)
-						updateIsFavorited(true);
-				},
-				{ modalUuid: props.modalUuid }
-			);
+		socket.on(
+			"event:station.removedAutofillPlaylist",
+			res => {
+				const { playlistId } = res.data;
+				const playlistIndex = autofill.value
+					.map(playlist => playlist._id)
+					.indexOf(playlistId);
+				if (playlistIndex >= 0) autofill.value.splice(playlistIndex, 1);
+			},
+			{ modalUuid: props.modalUuid }
+		);
 
-			socket.on(
-				"event:user.station.unfavorited",
-				res => {
-					if (res.data.stationId === stationId.value)
-						updateIsFavorited(false);
-				},
-				{ modalUuid: props.modalUuid }
-			);
-		} else {
-			new Toast(`Station with that ID not found`);
-			closeCurrentModal();
-		}
-	});
+		socket.on(
+			"event:station.removedBlacklistedPlaylist",
+			res => {
+				const { playlistId } = res.data;
+				const playlistIndex = blacklist.value
+					.map(playlist => playlist._id)
+					.indexOf(playlistId);
+				if (playlistIndex >= 0)
+					blacklist.value.splice(playlistIndex, 1);
+			},
+			{ modalUuid: props.modalUuid }
+		);
 
-	socket.on(
-		"event:manageStation.queue.updated",
-		res => {
-			if (res.data.stationId === stationId.value)
-				updateSongsList(res.data.queue);
-		},
-		{ modalUuid: props.modalUuid }
-	);
+		socket.on(
+			"event:station.deleted",
+			() => {
+				new Toast(`The station you were editing was deleted.`);
+				closeCurrentModal(true);
+			},
+			{ modalUuid: props.modalUuid }
+		);
 
-	socket.on(
-		"event:manageStation.queue.song.repositioned",
-		res => {
-			if (res.data.stationId === stationId.value)
-				repositionSongInList(res.data.song);
-		},
-		{ modalUuid: props.modalUuid }
-	);
+		socket.on(
+			"event:user.station.favorited",
+			res => {
+				if (res.data.stationId === stationId.value)
+					updateIsFavorited(true);
+			},
+			{ modalUuid: props.modalUuid }
+		);
 
-	socket.on(
-		"event:station.pause",
-		res => {
-			if (res.data.stationId === stationId.value)
-				updateStationPaused(true);
-		},
-		{ modalUuid: props.modalUuid }
-	);
+		socket.on(
+			"event:user.station.unfavorited",
+			res => {
+				if (res.data.stationId === stationId.value)
+					updateIsFavorited(false);
+			},
+			{ modalUuid: props.modalUuid }
+		);
 
-	socket.on(
-		"event:station.resume",
-		res => {
-			if (res.data.stationId === stationId.value)
-				updateStationPaused(false);
-		},
-		{ modalUuid: props.modalUuid }
-	);
+		socket.on(
+			"event:manageStation.queue.updated",
+			res => {
+				if (res.data.stationId === stationId.value)
+					updateSongsList(res.data.queue);
+			},
+			{ modalUuid: props.modalUuid }
+		);
 
-	socket.on(
-		"event:station.nextSong",
-		res => {
-			if (res.data.stationId === stationId.value)
-				updateCurrentSong(res.data.currentSong || {});
-		},
-		{ modalUuid: props.modalUuid }
-	);
+		socket.on(
+			"event:manageStation.queue.song.repositioned",
+			res => {
+				if (res.data.stationId === stationId.value)
+					repositionSongInList(res.data.song);
+			},
+			{ modalUuid: props.modalUuid }
+		);
 
-	if (isOwnerOrAdmin()) {
 		socket.on(
-			"event:playlist.song.added",
+			"event:station.pause",
 			res => {
-				if (stationPlaylist.value._id === res.data.playlistId)
-					stationPlaylist.value.songs.push(res.data.song);
+				if (res.data.stationId === stationId.value)
+					updateStationPaused(true);
 			},
-			{
-				modalUuid: props.modalUuid
-			}
+			{ modalUuid: props.modalUuid }
 		);
 
 		socket.on(
-			"event:playlist.song.removed",
+			"event:station.resume",
 			res => {
-				if (stationPlaylist.value._id === res.data.playlistId) {
-					// remove song from array of playlists
-					stationPlaylist.value.songs.forEach((song, index) => {
-						if (song.youtubeId === res.data.youtubeId)
-							stationPlaylist.value.songs.splice(index, 1);
-					});
-				}
+				if (res.data.stationId === stationId.value)
+					updateStationPaused(false);
 			},
-			{
-				modalUuid: props.modalUuid
-			}
+			{ modalUuid: props.modalUuid }
 		);
 
 		socket.on(
-			"event:playlist.songs.repositioned",
+			"event:station.nextSong",
 			res => {
-				if (stationPlaylist.value._id === res.data.playlistId) {
-					// for each song that has a new position
-					res.data.songsBeingChanged.forEach(changedSong => {
+				if (res.data.stationId === stationId.value)
+					updateCurrentSong(res.data.currentSong || {});
+			},
+			{ 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",
+				res => {
+					if (stationPlaylist.value._id === res.data.playlistId)
+						stationPlaylist.value.songs.push(res.data.song);
+				},
+				{
+					modalUuid: props.modalUuid
+				}
+			);
+
+			socket.on(
+				"event:playlist.song.removed",
+				res => {
+					if (stationPlaylist.value._id === res.data.playlistId) {
+						// remove song from array of playlists
 						stationPlaylist.value.songs.forEach((song, index) => {
-							// find song locally
-							if (song.youtubeId === changedSong.youtubeId) {
-								// change song position attribute
-								stationPlaylist.value.songs[index].position =
-									changedSong.position;
-
-								// reposition in array if needed
-								if (index !== changedSong.position - 1)
-									stationPlaylist.value.songs.splice(
-										changedSong.position - 1,
-										0,
-										stationPlaylist.value.songs.splice(
-											index,
-											1
-										)[0]
-									);
-							}
+							if (song.youtubeId === res.data.youtubeId)
+								stationPlaylist.value.songs.splice(index, 1);
+						});
+					}
+				},
+				{
+					modalUuid: props.modalUuid
+				}
+			);
+
+			socket.on(
+				"event:playlist.songs.repositioned",
+				res => {
+					if (stationPlaylist.value._id === res.data.playlistId) {
+						// for each song that has a new position
+						res.data.songsBeingChanged.forEach(changedSong => {
+							stationPlaylist.value.songs.forEach(
+								(song, index) => {
+									// find song locally
+									if (
+										song.youtubeId === changedSong.youtubeId
+									) {
+										// change song position attribute
+										stationPlaylist.value.songs[
+											index
+										].position = changedSong.position;
+
+										// reposition in array if needed
+										if (index !== changedSong.position - 1)
+											stationPlaylist.value.songs.splice(
+												changedSong.position - 1,
+												0,
+												stationPlaylist.value.songs.splice(
+													index,
+													1
+												)[0]
+											);
+									}
+								}
+							);
 						});
-					});
+					}
+				},
+				{
+					modalUuid: props.modalUuid
 				}
-			},
-			{
-				modalUuid: props.modalUuid
-			}
-		);
-	}
+			);
+		}
+	});
 });
 
 onBeforeUnmount(() => {
@@ -385,7 +450,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 +462,20 @@ onBeforeUnmount(() => {
 	<modal
 		v-if="station"
 		:title="
-			sector === 'home' && !isOwnerOrAdmin()
+			sector === 'home' && !hasPermission('stations.view.manage')
 				? 'View Queue'
-				: !isOwnerOrAdmin()
+				: !hasPermission('stations.view.manage')
 				? '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.view.manage') || sector !== 'home'
+				? 'wide'
+				: null
+		"
+		:split="hasPermission('stations.view.manage') || sector !== 'home'"
 	>
 		<template #body v-if="station && station._id">
 			<div class="left-section">
@@ -416,12 +485,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.view.manage') ||
+							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 +516,8 @@ onBeforeUnmount(() => {
 							</button>
 							<button
 								v-if="
-									isOwnerOrAdmin() && station.autofill.enabled
+									hasPermission('stations.autofill') &&
+									station.autofill.enabled
 								"
 								class="button is-default"
 								:class="{ selected: tab === 'autofill' }"
@@ -450,7 +527,7 @@ onBeforeUnmount(() => {
 								Autofill
 							</button>
 							<button
-								v-if="isOwnerOrAdmin()"
+								v-if="hasPermission('stations.blacklist')"
 								class="button is-default"
 								:class="{ selected: tab === 'blacklist' }"
 								:ref="el => (tabs['blacklist-tab'] = el)"
@@ -460,7 +537,7 @@ onBeforeUnmount(() => {
 							</button>
 						</div>
 						<settings
-							v-if="isOwnerOrAdmin()"
+							v-if="hasPermission('stations.update')"
 							class="tab"
 							v-show="tab === 'settings'"
 							:modal-uuid="modalUuid"
@@ -475,7 +552,10 @@ onBeforeUnmount(() => {
 							:modal-uuid="modalUuid"
 						/>
 						<playlist-tab-base
-							v-if="isOwnerOrAdmin() && station.autofill.enabled"
+							v-if="
+								hasPermission('stations.autofill') &&
+								station.autofill.enabled
+							"
 							class="tab"
 							v-show="tab === 'autofill'"
 							:type="'autofill'"
@@ -489,7 +569,7 @@ onBeforeUnmount(() => {
 							</template>
 						</playlist-tab-base>
 						<playlist-tab-base
-							v-if="isOwnerOrAdmin()"
+							v-if="hasPermission('stations.blacklist')"
 							class="tab"
 							v-show="tab === 'blacklist'"
 							:type="'blacklist'"
@@ -523,11 +603,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.reset')"
+					@confirm="resetQueue()"
+				>
 					<a class="button is-danger">Reset queue</a>
 				</quick-confirm>
-				<quick-confirm @confirm="removeStation()">
+				<quick-confirm
+					v-if="hasPermission('stations.remove')"
+					@confirm="removeStation()"
+				>
 					<button class="button is-danger">Delete station</button>
 				</quick-confirm>
 			</div>

+ 3 - 7
frontend/src/components/modals/RemoveAccount.vue

@@ -2,10 +2,8 @@
 import { defineAsyncComponent, ref, onMounted } from "vue";
 import { useRoute } from "vue-router";
 import Toast from "toasters";
-import { storeToRefs } from "pinia";
 import { useSettingsStore } from "@/stores/settings";
 import { useWebsocketsStore } from "@/stores/websockets";
-import { useRemoveAccountStore } from "@/stores/removeAccount";
 import { useModalsStore } from "@/stores/modals";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
@@ -14,7 +12,8 @@ const QuickConfirm = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	githubLinkConfirmed: { type: Boolean, default: false }
 });
 
 const settingsStore = useSettingsStore();
@@ -22,9 +21,6 @@ const route = useRoute();
 
 const { socket } = useWebsocketsStore();
 
-const removeAccountStore = useRemoveAccountStore(props);
-const { githubLinkConfirmed } = storeToRefs(removeAccountStore);
-
 const { isPasswordLinked, isGithubLinked } = settingsStore;
 
 const { closeCurrentModal } = useModalsStore();
@@ -114,7 +110,7 @@ onMounted(async () => {
 		"siteSettings.githubAuthentication"
 	);
 
-	if (githubLinkConfirmed.value === true) confirmGithubLink();
+	if (props.githubLinkConfirmed === true) confirmGithubLink();
 });
 </script>
 

+ 222 - 177
frontend/src/components/modals/Report.vue

@@ -1,11 +1,9 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref, onMounted, onBeforeUnmount } from "vue";
+import { defineAsyncComponent, ref, onMounted, computed } from "vue";
 import Toast from "toasters";
-import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
-import { useReportStore } from "@/stores/report";
-import ws from "@/ws";
+import { useForm } from "@/composables/useForm";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const SongItem = defineAsyncComponent(
@@ -16,144 +14,218 @@ const ReportInfoItem = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	song: { type: Object, required: true }
 });
 
 const { socket } = useWebsocketsStore();
 
-const reportStore = useReportStore(props);
-const { song } = storeToRefs(reportStore);
-
 const { openModal, closeCurrentModal } = useModalsStore();
 
 const existingReports = ref([]);
-const customIssues = ref([]);
-const predefinedCategories = ref([
+
+const { inputs, save } = useForm(
 	{
-		category: "video",
-		issues: [
-			{
-				enabled: false,
-				title: "Doesn't exist",
-				description: "",
-				showDescription: false
-			},
-			{
-				enabled: false,
-				title: "It's private",
-				description: "",
-				showDescription: false
-			},
-			{
-				enabled: false,
-				title: "It's not available in my country",
-				description: "",
-				showDescription: false
-			},
-			{
-				enabled: false,
-				title: "Unofficial",
-				description: "",
-				showDescription: false
+		video: {
+			value: {
+				category: "video",
+				issues: [
+					{
+						enabled: false,
+						title: "Doesn't exist",
+						description: "",
+						showDescription: false
+					},
+					{
+						enabled: false,
+						title: "It's private",
+						description: "",
+						showDescription: false
+					},
+					{
+						enabled: false,
+						title: "It's not available in my country",
+						description: "",
+						showDescription: false
+					},
+					{
+						enabled: false,
+						title: "Unofficial",
+						description: "",
+						showDescription: false
+					}
+				]
 			}
-		]
-	},
-	{
-		category: "title",
-		issues: [
-			{
-				enabled: false,
-				title: "Incorrect",
-				description: "",
-				showDescription: false
-			},
-			{
-				enabled: false,
-				title: "Inappropriate",
-				description: "",
-				showDescription: false
+		},
+		title: {
+			value: {
+				category: "title",
+				issues: [
+					{
+						enabled: false,
+						title: "Incorrect",
+						description: "",
+						showDescription: false
+					},
+					{
+						enabled: false,
+						title: "Inappropriate",
+						description: "",
+						showDescription: false
+					}
+				]
 			}
-		]
-	},
-	{
-		category: "duration",
-		issues: [
-			{
-				enabled: false,
-				title: "Skips too soon",
-				description: "",
-				showDescription: false
-			},
-			{
-				enabled: false,
-				title: "Skips too late",
-				description: "",
-				showDescription: false
-			},
-			{
-				enabled: false,
-				title: "Starts too soon",
-				description: "",
-				showDescription: false
-			},
-			{
-				enabled: false,
-				title: "Starts too late",
-				description: "",
-				showDescription: false
+		},
+		duration: {
+			value: {
+				category: "duration",
+				issues: [
+					{
+						enabled: false,
+						title: "Skips too soon",
+						description: "",
+						showDescription: false
+					},
+					{
+						enabled: false,
+						title: "Skips too late",
+						description: "",
+						showDescription: false
+					},
+					{
+						enabled: false,
+						title: "Starts too soon",
+						description: "",
+						showDescription: false
+					},
+					{
+						enabled: false,
+						title: "Starts too late",
+						description: "",
+						showDescription: false
+					}
+				]
 			}
-		]
-	},
-	{
-		category: "artists",
-		issues: [
-			{
-				enabled: false,
-				title: "Incorrect",
-				description: "",
-				showDescription: false
-			},
-			{
-				enabled: false,
-				title: "Inappropriate",
-				description: "",
-				showDescription: false
+		},
+		artists: {
+			value: {
+				category: "artists",
+				issues: [
+					{
+						enabled: false,
+						title: "Incorrect",
+						description: "",
+						showDescription: false
+					},
+					{
+						enabled: false,
+						title: "Inappropriate",
+						description: "",
+						showDescription: false
+					}
+				]
 			}
-		]
+		},
+		thumbnail: {
+			value: {
+				category: "thumbnail",
+				issues: [
+					{
+						enabled: false,
+						title: "Incorrect",
+						description: "",
+						showDescription: false
+					},
+					{
+						enabled: false,
+						title: "Inappropriate",
+						description: "",
+						showDescription: false
+					},
+					{
+						enabled: false,
+						title: "Doesn't exist",
+						description: "",
+						showDescription: false
+					}
+				]
+			}
+		},
+		custom: { value: [] }
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success") {
+			const issues: {
+				category: string;
+				title: string;
+				description?: string;
+			}[] = [];
+			Object.entries(values).forEach(([name, value]) => {
+				if (name === "custom")
+					value.forEach(issue => {
+						issues.push({ category: "custom", title: issue });
+					});
+				else
+					value.issues.forEach(issue => {
+						if (issue.enabled)
+							issues.push({
+								category: name,
+								title: issue.title,
+								description: issue.description
+							});
+					});
+			});
+			if (issues.length > 0)
+				socket.dispatch(
+					"reports.create",
+					{
+						issues,
+						youtubeId: props.song.youtubeId
+					},
+					res => {
+						if (res.status === "success") {
+							new Toast(res.message);
+							resolve();
+						} else reject(new Error(res.message));
+					}
+				);
+			else reject(new Error("Reports must have at least one issue"));
+		} else if (status === "unchanged")
+			reject(new Error("Reports must have at least one issue"));
+		else {
+			Object.values(messages).forEach(message => {
+				new Toast({ content: message, timeout: 8000 });
+			});
+			resolve();
+		}
 	},
 	{
-		category: "thumbnail",
-		issues: [
-			{
-				enabled: false,
-				title: "Incorrect",
-				description: "",
-				showDescription: false
-			},
-			{
-				enabled: false,
-				title: "Inappropriate",
-				description: "",
-				showDescription: false
-			},
-			{
-				enabled: false,
-				title: "Doesn't exist",
-				description: "",
-				showDescription: false
-			}
-		]
+		modalUuid: props.modalUuid
 	}
-]);
-
-const init = () => {
-	socket.dispatch("reports.myReportsForSong", song.value._id, res => {
-		if (res.status === "success") {
-			existingReports.value = res.data.reports;
-			existingReports.value.forEach(report =>
-				socket.dispatch("apis.joinRoom", `view-report.${report._id}`)
-			);
-		}
+);
+
+const categories = computed(() =>
+	Object.entries(inputs.value)
+		.filter(([name]) => name !== "custom")
+		.map(input => {
+			const { category, issues } = input[1].value;
+			return { category, issues };
+		})
+);
+
+onMounted(() => {
+	socket.onConnect(() => {
+		socket.dispatch("reports.myReportsForSong", props.song._id, res => {
+			if (res.status === "success") {
+				existingReports.value = res.data.reports;
+				existingReports.value.forEach(report =>
+					socket.dispatch(
+						"apis.joinRoom",
+						`view-report.${report._id}`
+					)
+				);
+			}
+		});
 	});
 
 	socket.on(
@@ -165,51 +237,16 @@ const init = () => {
 		},
 		{ modalUuid: props.modalUuid }
 	);
-};
-
-const create = () => {
-	const issues = [];
-
-	// any predefined issues that are enabled
-	predefinedCategories.value.forEach(category =>
-		category.issues.forEach(issue => {
-			if (issue.enabled)
-				issues.push({
-					category: category.category,
-					title: issue.title,
-					description: issue.description
-				});
-		})
-	);
-
-	// any custom issues
-	customIssues.value.forEach(issue =>
-		issues.push({ category: "custom", title: issue })
-	);
-
-	if (issues.length === 0)
-		return new Toast("Reports must have at least one issue");
 
-	return socket.dispatch(
-		"reports.create",
-		{
-			issues,
-			youtubeId: song.value.youtubeId
-		},
+	socket.on(
+		"event:admin.report.removed",
 		res => {
-			new Toast(res.message);
-			if (res.status === "success") closeCurrentModal();
-		}
+			existingReports.value = existingReports.value.filter(
+				report => report._id !== res.data.reportId
+			);
+		},
+		{ modalUuid: props.modalUuid }
 	);
-};
-
-onMounted(() => {
-	ws.onConnect(init);
-});
-
-onBeforeUnmount(() => {
-	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
-	reportStore.$dispose();
 });
 </script>
 
@@ -232,7 +269,7 @@ onBeforeUnmount(() => {
 
 						<div class="columns is-multiline">
 							<div
-								v-for="category in predefinedCategories"
+								v-for="category in categories"
 								class="column is-half"
 								:key="category.category"
 							>
@@ -301,7 +338,9 @@ onBeforeUnmount(() => {
 											class="button tab-actionable-button"
 											content="Add an issue that isn't listed"
 											v-tippy
-											@click="customIssues.push('')"
+											@click="
+												inputs.custom.value.push('')
+											"
 										>
 											<i
 												class="material-icons icon-with-button"
@@ -313,14 +352,17 @@ onBeforeUnmount(() => {
 
 									<div
 										class="custom-issue control is-grouped input-with-button"
-										v-for="(issue, index) in customIssues"
+										v-for="(issue, index) in inputs.custom
+											.value"
 										:key="index"
 									>
 										<p class="control is-expanded">
 											<input
 												type="text"
 												class="input"
-												v-model="customIssues[index]"
+												v-model="
+													inputs.custom.value[index]
+												"
 												placeholder="Provide information..."
 											/>
 										</p>
@@ -330,7 +372,7 @@ onBeforeUnmount(() => {
 												content="Remove custom issue"
 												v-tippy
 												@click="
-													customIssues.splice(
+													inputs.custom.value.splice(
 														index,
 														1
 													)
@@ -345,7 +387,7 @@ onBeforeUnmount(() => {
 
 									<p
 										id="no-issues-listed"
-										v-if="customIssues.length <= 0"
+										v-if="inputs.custom.value.length <= 0"
 									>
 										<em>
 											Add any issues that aren't listed
@@ -389,7 +431,7 @@ onBeforeUnmount(() => {
 											@click="
 												openModal({
 													modal: 'viewReport',
-													data: {
+													props: {
 														reportId: report._id
 													}
 												})
@@ -405,7 +447,10 @@ onBeforeUnmount(() => {
 				</div>
 			</template>
 			<template #footer>
-				<button class="button is-success" @click="create()">
+				<button
+					class="button is-success"
+					@click="save(closeCurrentModal)"
+				>
 					<i class="material-icons save-changes">done</i>
 					<span>&nbsp;Create</span>
 				</button>

+ 39 - 42
frontend/src/components/modals/ViewApiRequest.vue

@@ -2,11 +2,8 @@
 import { defineAsyncComponent, ref, onMounted, onBeforeUnmount } from "vue";
 import Toast from "toasters";
 import VueJsonPretty from "vue-json-pretty";
-import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
-import { useViewApiRequestStore } from "@/stores/viewApiRequest";
-import ws from "@/ws";
 import "vue-json-pretty/lib/styles.css";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
@@ -15,71 +12,71 @@ const QuickConfirm = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	requestId: { type: String, required: true },
+	removeAction: { type: String, required: true }
 });
 
 const { socket } = useWebsocketsStore();
 
-const viewApiRequestStore = useViewApiRequestStore(props);
-const { requestId, request, removeAction } = storeToRefs(viewApiRequestStore);
-const { viewApiRequest } = viewApiRequestStore;
-
 const { closeCurrentModal } = useModalsStore();
 
 const loaded = ref(false);
+const request = ref({
+	_id: null,
+	url: null,
+	params: {},
+	results: [],
+	date: null,
+	quotaCost: null
+});
 
-const init = () => {
-	loaded.value = false;
-	socket.dispatch("youtube.getApiRequest", requestId.value, res => {
+const remove = () => {
+	socket.dispatch(props.removeAction, props.requestId, res => {
 		if (res.status === "success") {
-			const { apiRequest } = res.data;
-			viewApiRequest(apiRequest);
-			loaded.value = true;
-
-			socket.dispatch(
-				"apis.joinRoom",
-				`view-api-request.${requestId.value}`
-			);
-
-			socket.on(
-				"event:youtubeApiRequest.removed",
-				() => {
-					new Toast("This API request was removed.");
-					closeCurrentModal();
-				},
-				{ modalUuid: props.modalUuid }
-			);
-		} else {
-			new Toast("API request with that ID not found");
+			new Toast("API request successfully removed.");
 			closeCurrentModal();
+		} else {
+			new Toast("API request with that ID not found.");
 		}
 	});
 };
 
-const remove = () => {
-	if (removeAction.value)
-		socket.dispatch(removeAction.value, requestId.value, res => {
+onMounted(() => {
+	socket.onConnect(() => {
+		loaded.value = false;
+		socket.dispatch("youtube.getApiRequest", props.requestId, res => {
 			if (res.status === "success") {
-				new Toast("API request successfully removed.");
-				closeCurrentModal();
+				request.value = res.data.apiRequest;
+				loaded.value = true;
+
+				socket.dispatch(
+					"apis.joinRoom",
+					`view-api-request.${props.requestId}`
+				);
 			} else {
-				new Toast("API request with that ID not found.");
+				new Toast("API request with that ID not found");
+				closeCurrentModal();
 			}
 		});
-};
+	});
 
-onMounted(() => {
-	ws.onConnect(init);
+	socket.on(
+		"event:youtubeApiRequest.removed",
+		() => {
+			new Toast("This API request was removed.");
+			closeCurrentModal();
+		},
+		{ modalUuid: props.modalUuid }
+	);
 });
 
 onBeforeUnmount(() => {
 	socket.dispatch(
 		"apis.leaveRoom",
-		`view-api-request.${requestId.value}`,
+		`view-api-request.${props.requestId}`,
 		() => {}
 	);
-	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
-	viewApiRequestStore.$dispose();
 });
 </script>
 

+ 30 - 39
frontend/src/components/modals/ViewPunishment.vue

@@ -1,11 +1,8 @@
 <script setup lang="ts">
-import { defineAsyncComponent, onMounted, onBeforeUnmount } from "vue";
+import { defineAsyncComponent, ref, onMounted, onBeforeUnmount } from "vue";
 import Toast from "toasters";
-import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
-import { useViewPunishmentStore } from "@/stores/viewPunishment";
-import ws from "@/ws";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const PunishmentItem = defineAsyncComponent(
@@ -13,49 +10,24 @@ const PunishmentItem = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	punishmentId: { type: String, required: true }
 });
 
 const { socket } = useWebsocketsStore();
 
-const viewPunishmentStore = useViewPunishmentStore(props);
-const { punishmentId, punishment } = storeToRefs(viewPunishmentStore);
-const { viewPunishment } = viewPunishmentStore;
-
 const { closeCurrentModal } = useModalsStore();
 
-const init = () => {
-	socket.dispatch(`punishments.findOne`, punishmentId.value, res => {
-		if (res.status === "success") {
-			viewPunishment(res.data.punishment);
-
-			socket.dispatch(
-				"apis.joinRoom",
-				`view-punishment.${punishmentId.value}`
-			);
-
-			socket.on(
-				"event:admin.punishment.updated",
-				({ data }) => {
-					punishment.value = data.punishment;
-				},
-				{ modalUuid: props.modalUuid }
-			);
-		} else {
-			new Toast("Punishment with that ID not found");
-			closeCurrentModal();
-		}
-	});
-};
+const punishment = ref({});
 
 const deactivatePunishment = event => {
 	event.preventDefault();
 	socket.dispatch(
 		"punishments.deactivatePunishment",
-		punishmentId.value,
+		props.punishmentId,
 		res => {
 			if (res.status === "success") {
-				viewPunishmentStore.deactivatePunishment();
+				punishment.value.active = false;
 			} else {
 				new Toast(res.message);
 			}
@@ -64,18 +36,37 @@ const deactivatePunishment = event => {
 };
 
 onMounted(() => {
-	ws.onConnect(init);
+	socket.onConnect(() => {
+		socket.dispatch(`punishments.findOne`, props.punishmentId, res => {
+			if (res.status === "success") {
+				punishment.value = res.data.punishment;
+
+				socket.dispatch(
+					"apis.joinRoom",
+					`view-punishment.${props.punishmentId}`
+				);
+			} else {
+				new Toast("Punishment with that ID not found");
+				closeCurrentModal();
+			}
+		});
+	});
+
+	socket.on(
+		"event:admin.punishment.updated",
+		({ data }) => {
+			punishment.value = data.punishment;
+		},
+		{ modalUuid: props.modalUuid }
+	);
 });
 
 onBeforeUnmount(() => {
 	socket.dispatch(
 		"apis.leaveRoom",
-		`view-punishment.${punishmentId.value}`,
+		`view-punishment.${props.punishmentId}`,
 		() => {}
 	);
-
-	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
-	viewPunishmentStore.$dispose();
 });
 </script>
 

+ 97 - 73
frontend/src/components/modals/ViewReport.vue

@@ -1,12 +1,16 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref, onMounted, onBeforeUnmount } from "vue";
+import {
+	defineAsyncComponent,
+	ref,
+	watch,
+	onMounted,
+	onBeforeUnmount
+} from "vue";
 import Toast from "toasters";
-import { storeToRefs } from "pinia";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
-import { useViewReportStore } from "@/stores/viewReport";
+import { useUserAuthStore } from "@/stores/userAuth";
 import { useReports } from "@/composables/useReports";
-import ws from "@/ws";
 import { Report } from "@/types/report";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
@@ -21,18 +25,19 @@ const QuickConfirm = defineAsyncComponent(
 );
 
 const props = defineProps({
-	modalUuid: { type: String, default: "" }
+	modalUuid: { type: String, required: true },
+	reportId: { type: String, required: true }
 });
 
 const { socket } = useWebsocketsStore();
 
-const viewReportStore = useViewReportStore(props);
-const { reportId } = storeToRefs(viewReportStore);
-
 const { openModal, closeCurrentModal } = useModalsStore();
 
 const { resolveReport, removeReport } = useReports();
 
+const userAuthStore = useUserAuthStore();
+const { hasPermission } = userAuthStore;
+
 const icons = ref({
 	duration: "timer",
 	video: "tv",
@@ -41,76 +46,25 @@ const icons = ref({
 	title: "title",
 	custom: "lightbulb"
 });
-const report = ref(<Report>{});
+const report = ref<Report>({});
 const song = ref();
 
-const init = () => {
-	socket.dispatch("reports.findOne", reportId.value, res => {
-		if (res.status === "success") {
-			report.value = res.data.report;
-
-			socket.dispatch("apis.joinRoom", `view-report.${reportId.value}`);
-
-			socket.dispatch(
-				"songs.getSongFromSongId",
-				report.value.song._id,
-				res => {
-					if (res.status === "success") song.value = res.data.song;
-					else {
-						new Toast("Cannot find the report's associated song");
-						closeCurrentModal();
-					}
-				}
-			);
-
-			socket.on(
-				"event:admin.report.resolved",
-				res => {
-					report.value.resolved = res.data.resolved;
-				},
-				{ modalUuid: props.modalUuid }
-			);
-
-			socket.on("event:admin.report.removed", () => closeCurrentModal(), {
-				modalUuid: props.modalUuid
-			});
-
-			socket.on(
-				"event:admin.report.issue.toggled",
-				res => {
-					if (reportId.value === res.data.reportId) {
-						const issue = report.value.issues.find(
-							issue => issue._id.toString() === res.data.issueId
-						);
-
-						issue.resolved = res.data.resolved;
-					}
-				},
-				{ modalUuid: props.modalUuid }
-			);
-		} else {
-			new Toast("Report with that ID not found");
-			closeCurrentModal();
-		}
-	});
-};
-
 const resolve = value =>
-	resolveReport({ reportId: reportId.value, value })
+	resolveReport({ reportId: props.reportId, value })
 		.then((res: any) => {
 			if (res.status !== "success") new Toast(res.message);
 		})
 		.catch(err => new Toast(err.message));
 
 const remove = () =>
-	removeReport(reportId.value)
+	removeReport(props.reportId)
 		.then((res: any) => {
 			if (res.status === "success") closeCurrentModal();
 		})
 		.catch(err => new Toast(err.message));
 
 const toggleIssue = issueId => {
-	socket.dispatch("reports.toggleIssue", reportId.value, issueId, res => {
+	socket.dispatch("reports.toggleIssue", props.reportId, issueId, res => {
 		if (res.status !== "success") new Toast(res.message);
 	});
 };
@@ -118,18 +72,78 @@ const toggleIssue = issueId => {
 const openSong = () => {
 	openModal({
 		modal: "editSong",
-		data: { song: report.value.song }
+		props: { song: report.value.song }
 	});
 };
 
+watch(
+	() => hasPermission("reports.get"),
+	value => {
+		if (!value) closeCurrentModal();
+	}
+);
+
 onMounted(() => {
-	ws.onConnect(init);
+	socket.onConnect(() => {
+		socket.dispatch("reports.findOne", props.reportId, res => {
+			if (res.status === "success") {
+				report.value = res.data.report;
+
+				socket.dispatch(
+					"apis.joinRoom",
+					`view-report.${props.reportId}`
+				);
+
+				socket.dispatch(
+					"songs.getSongFromSongId",
+					report.value.song._id,
+					res => {
+						if (res.status === "success")
+							song.value = res.data.song;
+						else {
+							new Toast(
+								"Cannot find the report's associated song"
+							);
+							closeCurrentModal();
+						}
+					}
+				);
+			} else {
+				new Toast("Report with that ID not found");
+				closeCurrentModal();
+			}
+		});
+	});
+
+	socket.on(
+		"event:admin.report.resolved",
+		res => {
+			report.value.resolved = res.data.resolved;
+		},
+		{ modalUuid: props.modalUuid }
+	);
+
+	socket.on("event:admin.report.removed", () => closeCurrentModal(), {
+		modalUuid: props.modalUuid
+	});
+
+	socket.on(
+		"event:admin.report.issue.toggled",
+		res => {
+			if (props.reportId === res.data.reportId) {
+				const issue = report.value.issues.find(
+					issue => issue._id.toString() === res.data.issueId
+				);
+
+				issue.resolved = res.data.resolved;
+			}
+		},
+		{ modalUuid: props.modalUuid }
+	);
 });
 
 onBeforeUnmount(() => {
-	socket.dispatch("apis.leaveRoom", `view-report.${reportId.value}`);
-	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
-	viewReportStore.$dispose();
+	socket.dispatch("apis.leaveRoom", `view-report.${props.reportId}`);
 });
 </script>
 
@@ -188,7 +202,10 @@ onBeforeUnmount(() => {
 								class="material-icons resolve-icon"
 								content="Resolve"
 								v-tippy
-								v-if="!issue.resolved"
+								v-if="
+									!issue.resolved &&
+									hasPermission('reports.update')
+								"
 								@click="toggleIssue(issue._id)"
 							>
 								done
@@ -197,7 +214,10 @@ onBeforeUnmount(() => {
 								class="material-icons unresolve-icon"
 								content="Unresolve"
 								v-tippy
-								v-else
+								v-else-if="
+									issue.resolved &&
+									hasPermission('reports.update')
+								"
 								@click="toggleIssue(issue._id)"
 							>
 								remove
@@ -209,6 +229,7 @@ onBeforeUnmount(() => {
 		</template>
 		<template #footer v-if="report && report._id">
 			<a
+				v-if="hasPermission('songs.update')"
 				class="button is-primary material-icons icon-with-button"
 				@click="openSong()"
 				content="Edit Song"
@@ -217,7 +238,7 @@ onBeforeUnmount(() => {
 				edit
 			</a>
 			<button
-				v-if="report.resolved"
+				v-if="report.resolved && hasPermission('reports.update')"
 				class="button is-danger material-icons icon-with-button"
 				@click="resolve(false)"
 				content="Unresolve"
@@ -226,7 +247,7 @@ onBeforeUnmount(() => {
 				remove_done
 			</button>
 			<button
-				v-else
+				v-else-if="!report.resolved && hasPermission('reports.update')"
 				class="button is-success material-icons icon-with-button"
 				@click="resolve(true)"
 				content="Resolve"
@@ -235,7 +256,10 @@ onBeforeUnmount(() => {
 				done_all
 			</button>
 			<div class="right">
-				<quick-confirm @confirm="remove()">
+				<quick-confirm
+					v-if="hasPermission('reports.remove')"
+					@confirm="remove()"
+				>
 					<button
 						class="button is-danger material-icons icon-with-button"
 						content="Delete Report"

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików