浏览代码

Merge branch 'polishing' into kris-responsive-station

Kristian Vos 3 年之前
父节点
当前提交
2edfd5ef3b
共有 46 个文件被更改,包括 427 次插入136 次删除
  1. 2 1
      backend/config/template.json
  2. 1 1
      backend/index.js
  3. 102 0
      backend/logic/actions/dataRequests.js
  4. 2 0
      backend/logic/actions/index.js
  5. 2 0
      backend/logic/actions/stations.js
  6. 32 10
      backend/logic/actions/users.js
  7. 0 2
      backend/logic/app.js
  8. 8 0
      backend/logic/db/index.js
  9. 7 0
      backend/logic/db/schemas/dataRequest.js
  10. 3 1
      backend/logic/mail/index.js
  11. 34 0
      backend/logic/mail/schemas/dataRequest.js
  12. 0 3
      backend/logic/mail/schemas/verifyEmail.js
  13. 5 2
      frontend/dist/config/template.json
  14. 1 1
      frontend/src/components/AddToPlaylistDropdown.vue
  15. 1 0
      frontend/src/components/ProfilePicture.vue
  16. 1 1
      frontend/src/components/Queue.vue
  17. 2 2
      frontend/src/components/layout/MainHeader.vue
  18. 8 8
      frontend/src/components/modals/EditNews.vue
  19. 17 5
      frontend/src/components/modals/EditPlaylist.vue
  20. 37 14
      frontend/src/components/modals/EditSong.vue
  21. 2 2
      frontend/src/components/modals/ManageStation/Tabs/Blacklist.vue
  22. 4 4
      frontend/src/components/modals/ManageStation/Tabs/Playlists.vue
  23. 3 3
      frontend/src/components/modals/ManageStation/Tabs/Search.vue
  24. 15 5
      frontend/src/components/modals/RemoveAccount.vue
  25. 4 4
      frontend/src/components/modals/Report.vue
  26. 1 1
      frontend/src/components/modals/RequestSong.vue
  27. 1 1
      frontend/src/components/modals/ViewReport.vue
  28. 5 11
      frontend/src/components/modals/WhatIsNew.vue
  29. 1 1
      frontend/src/main.js
  30. 1 1
      frontend/src/pages/Admin/tabs/HiddenSongs.vue
  31. 5 5
      frontend/src/pages/Admin/tabs/News.vue
  32. 2 0
      frontend/src/pages/Admin/tabs/Playlists.vue
  33. 2 2
      frontend/src/pages/Admin/tabs/Punishments.vue
  34. 1 1
      frontend/src/pages/Admin/tabs/Reports.vue
  35. 3 3
      frontend/src/pages/Admin/tabs/Stations.vue
  36. 2 2
      frontend/src/pages/Admin/tabs/UnverifiedSongs.vue
  37. 58 1
      frontend/src/pages/Admin/tabs/Users.vue
  38. 1 1
      frontend/src/pages/Admin/tabs/VerifiedSongs.vue
  39. 7 7
      frontend/src/pages/Home.vue
  40. 9 15
      frontend/src/pages/News.vue
  41. 16 0
      frontend/src/pages/Settings/tabs/Account.vue
  42. 5 0
      frontend/src/pages/Settings/tabs/Profile.vue
  43. 2 2
      frontend/src/pages/Station/Sidebar/Playlists.vue
  44. 1 1
      frontend/src/pages/Station/Sidebar/Users.vue
  45. 10 12
      frontend/src/pages/Team.vue
  46. 1 0
      frontend/src/validation.js

+ 2 - 1
backend/config/template.json

@@ -9,6 +9,7 @@
 	"registrationDisabled": true,
 	"hideAutomaticallyRequestedSongs": false,
     "hideAnonymousSongs": false,
+	"sendDataRequestEmails": true,
 	"fancyConsole": true,
 	"apis": {
 		"youtube": {
@@ -91,5 +92,5 @@
 			]
 		}
 	},
-	"configVersion": 5
+	"configVersion": 6
 }

+ 1 - 1
backend/index.js

@@ -3,7 +3,7 @@ import "./loadEnvVariables.js";
 import util from "util";
 import config from "config";
 
-const REQUIRED_CONFIG_VERSION = 5;
+const REQUIRED_CONFIG_VERSION = 6;
 
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {

+ 102 - 0
backend/logic/actions/dataRequests.js

@@ -0,0 +1,102 @@
+import async from "async";
+
+import { isAdminRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+
+CacheModule.runJob("SUB", {
+	channel: "dataRequest.resolve",
+	cb: dataRequestId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.users",
+			args: ["event:admin.dataRequests.resolved", { data: { dataRequestId } }]
+		});
+	}
+});
+
+export default {
+	/**
+	 * Gets all unresolved data requests
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: isAdminRequired(async function index(session, cb) {
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					dataRequestModel.find({ resolved: false }).sort({ createdAt: "desc" }).exec(next);
+				}
+			],
+			async (err, requests) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "DATA_REQUESTS_INDEX", `Indexing data requests failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "DATA_REQUESTS_INDEX", `Indexing data requests successful.`, false);
+
+				return cb({ status: "success", data: { requests } });
+			}
+		);
+	}),
+
+	/**
+	 * Resolves a data request
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} dataRequestId - the id of the data request to resolve
+	 * @param {Function} cb - gets called with the result
+	 */
+	resolve: isAdminRequired(async function update(session, dataRequestId, cb) {
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!dataRequestId || typeof dataRequestId !== "string")
+						return next("Please provide a data request id.");
+					return next();
+				},
+
+				next => {
+					dataRequestModel.updateOne({ _id: dataRequestId }, { resolved: true }, { upsert: true }, err =>
+						next(err)
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"DATA_REQUESTS_RESOLVE",
+						`Resolving data request ${dataRequestId} failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "dataRequest.resolve", value: dataRequestId });
+
+				this.log(
+					"SUCCESS",
+					"DATA_REQUESTS_RESOLVE",
+					`Resolving data request "${dataRequestId}" successful for user ${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully resolved data request."
+				});
+			}
+		);
+	})
+};

+ 2 - 0
backend/logic/actions/index.js

@@ -3,6 +3,7 @@ import songs from "./songs";
 import stations from "./stations";
 import playlists from "./playlists";
 import users from "./users";
+import dataRequests from "./dataRequests";
 import activities from "./activities";
 import reports from "./reports";
 import news from "./news";
@@ -15,6 +16,7 @@ export default {
 	stations,
 	playlists,
 	users,
+	dataRequests,
 	activities,
 	reports,
 	news,

+ 2 - 0
backend/logic/actions/stations.js

@@ -3426,6 +3426,8 @@ export default {
 					if (!station) return next("Station not found.");
 					if (station.includedPlaylists.indexOf(playlistId) !== -1)
 						return next("That playlist is already included.");
+					if (station.playMode === "sequential" && station.includedPlaylists.length > 0)
+						return next("Error: Only 1 playlist can be included in sequential play mode.");
 					return next();
 				},
 

+ 32 - 10
backend/logic/actions/users.js

@@ -215,10 +215,13 @@ export default {
 	 */
 	remove: isLoginRequired(async function remove(session, 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);
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
 
+		const dataRequestEmail = await MailModule.runJob("GET_SCHEMA", { schemaName: "dataRequest" }, this);
+
 		async.waterfall(
 			[
 				// activities related to the user
@@ -267,6 +270,33 @@ export default {
 				// user object
 				(res, next) => {
 					userModel.deleteMany({ _id: session.userId }, next);
+				},
+
+				// request data removal for user
+				(res, next) => {
+					dataRequestModel.create({ userId: session.userId, type: "remove" }, next);
+				},
+
+				(request, next) => {
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "admin.users",
+						args: ["event:admin.dataRequests.created", { data: { request } }]
+					});
+
+					return next();
+				},
+
+				next => userModel.find({ role: "admin" }, next),
+
+				// send email to all admins of a data removal request
+				(users, next) => {
+					if (!config.get("sendDataRequestEmails")) return next();
+					if (users.length === 0) return next();
+
+					const to = [];
+					users.forEach(user => to.push(user.email.address));
+
+					return dataRequestEmail(to, session.userId, "remove", err => next(err));
 				}
 			],
 			async err => {
@@ -349,9 +379,7 @@ export default {
 						},
 						this
 					)
-						.then(() => {
-							next(null, sessionId);
-						})
+						.then(() => next(null, sessionId))
 						.catch(next);
 				}
 			],
@@ -392,13 +420,7 @@ export default {
 		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
 
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		const verifyEmailSchema = await MailModule.runJob(
-			"GET_SCHEMA",
-			{
-				schemaName: "verifyEmail"
-			},
-			this
-		);
+		const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
 
 		async.waterfall(
 			[

+ 0 - 2
backend/logic/app.js

@@ -374,8 +374,6 @@ class _AppModule extends CoreClass {
 						}
 					],
 					async (err, userId) => {
-						console.log(err, userId);
-
 						if (err && err !== true) {
 							err = await UtilsModule.runJob("GET_ERROR", {
 								error: err

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

@@ -22,6 +22,7 @@ const regex = {
 	az09_: /^[a-z0-9_]+$/,
 	emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,
 	ascii: /^[\x00-\x7F]+$/,
+	name: /^[\p{L} .'-]+$/u,
 	custom: regex => new RegExp(`^[${regex}]+$`)
 };
 
@@ -65,6 +66,7 @@ class _DBModule extends CoreClass {
 						queueSong: {},
 						station: {},
 						user: {},
+						dataRequest: {},
 						activity: {},
 						playlist: {},
 						news: {},
@@ -84,6 +86,7 @@ class _DBModule extends CoreClass {
 					await importSchema("queueSong");
 					await importSchema("station");
 					await importSchema("user");
+					await importSchema("dataRequest");
 					await importSchema("activity");
 					await importSchema("playlist");
 					await importSchema("news");
@@ -95,6 +98,7 @@ class _DBModule extends CoreClass {
 						queueSong: mongoose.model("queueSong", this.schemas.queueSong),
 						station: mongoose.model("station", this.schemas.station),
 						user: mongoose.model("user", this.schemas.user),
+						dataRequest: mongoose.model("dataRequest", this.schemas.dataRequest),
 						activity: mongoose.model("activity", this.schemas.activity),
 						playlist: mongoose.model("playlist", this.schemas.playlist),
 						news: mongoose.model("news", this.schemas.news),
@@ -137,6 +141,10 @@ class _DBModule extends CoreClass {
 						return regex.emailSimple.test(email) && regex.ascii.test(email);
 					}, "Invalid email.");
 
+					this.schemas.user
+						.path("name")
+						.validate(name => isLength(name, 1, 64) && regex.name.test(name), "Invalid name.");
+
 					// Station
 					this.schemas.station
 						.path("name")

+ 7 - 0
backend/logic/db/schemas/dataRequest.js

@@ -0,0 +1,7 @@
+export default {
+	userId: { type: String, required: true },
+	createdAt: { type: Date, default: Date.now, required: true },
+	type: { type: String, required: true, enum: ["remove"] },
+	resolved: { type: Boolean, default: false },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 3 - 1
backend/logic/mail/index.js

@@ -28,7 +28,8 @@ class _MailModule extends CoreClass {
 		this.schemas = {
 			verifyEmail: await importSchema("verifyEmail"),
 			resetPasswordRequest: await importSchema("resetPasswordRequest"),
-			passwordRequest: await importSchema("passwordRequest")
+			passwordRequest: await importSchema("passwordRequest"),
+			dataRequest: await importSchema("dataRequest")
 		};
 
 		this.enabled = config.get("smtp.enabled");
@@ -55,6 +56,7 @@ class _MailModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	SEND_MAIL(payload) {
+		// console.log(payload);
 		return new Promise((resolve, reject) => {
 			if (MailModule.enabled)
 				return MailModule.transporter

+ 34 - 0
backend/logic/mail/schemas/dataRequest.js

@@ -0,0 +1,34 @@
+import config from "config";
+
+import mail from "../index";
+
+/**
+ * Sends an email to all admins that a user has submitted a data request
+ *
+ * @param {string} to - an array of email addresses of admins
+ * @param {string} userId - the id of the user the data request is for
+ * @param {string} type - the type of data request e.g. remove
+ * @param {Function} cb - gets called when an error occurred or when the operation was successful
+ */
+export default (to, userId, type, cb) => {
+	const data = {
+		from: "Musare <noreply@musare.com>",
+		to,
+		subject: `Data Request - ${type}`,
+		html: `
+				Hello,
+				<br>
+				<br>
+				User ${userId} has requested to ${type} the data for their account on Musare.
+				<br>
+				<br>
+				This request can be viewed and resolved in the <a href="${config.get(
+					"domain"
+				)}/admin/users">Users tab of the admin page</a>. Note: All admins will be sent the same message.
+			`
+	};
+
+	mail.runJob("SEND_MAIL", { data })
+		.then(() => cb())
+		.catch(err => cb(err));
+};

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

@@ -1,7 +1,4 @@
 import config from "config";
-
-// const moduleManager = require('../../../index');
-
 import mail from "../index";
 
 /**

+ 5 - 2
frontend/dist/config/template.json

@@ -15,9 +15,12 @@
 	"siteSettings": {
 		"logo_white": "/assets/white_wordmark.png",
 		"logo_blue": "/assets/blue_wordmark.png",
-		"siteName": "Musare",
+		"sitename": "Musare",
 		"github": "https://github.com/Musare/MusareNode"
 	},
+	"messages": {
+		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."
+	},
 	"skipConfigVersionCheck": false,
-	"configVersion": 2
+	"configVersion": 3
 }

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

@@ -25,7 +25,7 @@
 				class="nav-item"
 				href="#"
 				v-for="(playlist, index) in playlists"
-				:key="index"
+				:key="playlist._id"
 				@click.prevent="toggleSongInPlaylist(index)"
 				:title="playlist.displayName"
 			>

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

@@ -34,6 +34,7 @@ export default {
 			return this.name
 				.split(" ")
 				.map(word => word.charAt(0))
+				.splice(0, 2)
 				.join("")
 				.toUpperCase();
 		}

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

@@ -18,7 +18,7 @@
 			>
 				<song-item
 					v-for="(song, index) in queue"
-					:key="`queue-${index}`"
+					:key="`queue-${song._id}`"
 					:song="song"
 					:requested-by="
 						station.type === 'community' &&

+ 2 - 2
frontend/src/components/layout/MainHeader.vue

@@ -4,7 +4,7 @@
 			<router-link v-if="!hideLogo" class="nav-item is-brand" to="/">
 				<img
 					:src="`${this.siteSettings.logo_white}`"
-					:alt="`${this.siteSettings.siteName}` || `Musare`"
+					:alt="`${this.siteSettings.sitename}` || `Musare`"
 				/>
 			</router-link>
 		</div>
@@ -71,7 +71,7 @@ export default {
 			frontendDomain: "",
 			siteSettings: {
 				logo: "",
-				siteName: ""
+				sitename: ""
 			}
 		};
 	},

+ 8 - 8
frontend/src/components/modals/EditNews.vue

@@ -36,9 +36,9 @@
 						>
 					</p>
 					<span
-						v-for="(bug, index) in news.bugs"
+						v-for="bug in news.bugs"
 						class="tag is-info"
-						:key="index"
+						:key="bug"
 					>
 						{{ bug }}
 						<button
@@ -65,9 +65,9 @@
 						>
 					</p>
 					<span
-						v-for="(feature, index) in news.features"
+						v-for="feature in news.features"
 						class="tag is-info"
-						:key="index"
+						:key="feature"
 					>
 						{{ feature }}
 						<button
@@ -97,9 +97,9 @@
 						>
 					</p>
 					<span
-						v-for="(improvement, index) in news.improvements"
+						v-for="improvement in news.improvements"
 						class="tag is-info"
-						:key="index"
+						:key="improvement"
 					>
 						{{ improvement }}
 						<button
@@ -126,9 +126,9 @@
 						>
 					</p>
 					<span
-						v-for="(upcoming, index) in news.upcoming"
+						v-for="upcoming in news.upcoming"
 						class="tag is-info"
-						:key="index"
+						:key="upcoming"
 					>
 						{{ upcoming }}
 						<button

+ 17 - 5
frontend/src/components/modals/EditPlaylist.vue

@@ -21,10 +21,14 @@
 
 				<div
 					id="playlist-settings-section"
-					v-if="userId === playlist.createdBy || isEditable()"
+					v-if="
+						userId === playlist.createdBy ||
+							isEditable() ||
+							(playlist.type === 'genre' && isAdmin())
+					"
 					class="section"
 				>
-					<div v-if="isEditable()">
+					<div v-if="userId === playlist.createdBy || isEditable()">
 						<h4 class="section-title">Edit Details</h4>
 
 						<p class="section-description">
@@ -56,7 +60,12 @@
 						</div>
 					</div>
 
-					<div>
+					<div
+						v-if="
+							isEditable() ||
+								(playlist.type === 'genre' && isAdmin())
+						"
+					>
 						<label class="label"> Change privacy </label>
 						<div class="control is-grouped input-with-button">
 							<div class="control is-expanded select">
@@ -160,7 +169,7 @@
 					>
 						<search-query-item
 							v-for="(result, index) in search.songs.results"
-							:key="index"
+							:key="result.id"
 							:result="result"
 						>
 							<div slot="actions">
@@ -241,7 +250,7 @@
 							>
 								<li
 									v-for="(song, index) in playlist.songs"
-									:key="'key-' + song._id"
+									:key="`key-${song._id}`"
 								>
 									<song-item
 										:song="song"
@@ -572,6 +581,9 @@ export default {
 					this.userRole === "admin")
 			);
 		},
+		isAdmin() {
+			return this.userRole === "admin";
+		},
 		updateSongPositioning({ moved }) {
 			if (!moved) return; // we only need to update when song is moved
 

+ 37 - 14
frontend/src/components/modals/EditSong.vue

@@ -76,6 +76,7 @@
 							class="thumbnail-preview"
 							:src="song.thumbnail"
 							onerror="this.src='/assets/notes-transparent.png'"
+							ref="thumbnailElement"
 							v-if="songDataLoaded"
 						/>
 					</div>
@@ -193,17 +194,16 @@
 										class="autosuggest-item"
 										tabindex="0"
 										@click="selectArtistAutosuggest(item)"
-										v-for="(item,
-										index) in artistAutosuggestItems"
-										:key="index"
+										v-for="item in artistAutosuggestItems"
+										:key="item"
 										>{{ item }}</span
 									>
 								</div>
 								<div class="list-container">
 									<div
 										class="list-item"
-										v-for="(artist, index) in song.artists"
-										:key="index"
+										v-for="artist in song.artists"
+										:key="artist"
 									>
 										<div
 											class="list-item-circle"
@@ -265,17 +265,16 @@
 									<span
 										class="autosuggest-item"
 										@click="selectGenreAutosuggest(item)"
-										v-for="(item,
-										index) in genreAutosuggestItems"
-										:key="index"
+										v-for="item in genreAutosuggestItems"
+										:key="item"
 										>{{ item }}</span
 									>
 								</div>
 								<div class="list-container">
 									<div
 										class="list-item"
-										v-for="(genre, index) in song.genres"
-										:key="index"
+										v-for="genre in song.genres"
+										:key="genre"
 									>
 										<div
 											class="list-item-circle"
@@ -381,7 +380,7 @@
 							<div
 								class="api-result"
 								v-for="(result, index) in discogs.apiResults"
-								:key="index"
+								:key="result.album.id"
 								tabindex="0"
 								@keydown.space.prevent
 								@keyup.enter="toggleAPIResult(index)"
@@ -441,7 +440,9 @@
 											tabindex="0"
 											v-for="(track,
 											trackIndex) in result.tracks"
-											:key="trackIndex"
+											:key="
+												`${track.position}-${track.title}`
+											"
 											@click="
 												selectTrack(index, trackIndex)
 											"
@@ -593,10 +594,10 @@ export default {
 	},
 	watch: {
 		/* eslint-disable */
-		"song.duration": function () {
+		"song.duration": function() {
 			this.drawCanvas();
 		},
-		"song.skipDuration": function () {
+		"song.skipDuration": function() {
 			this.drawCanvas();
 		}
 		/* eslint-enable */
@@ -947,6 +948,28 @@ export default {
 				return new Toast("Please fill in all fields");
 			}
 
+			const thumbnailHeight = this.$refs.thumbnailElement.naturalHeight;
+			const thumbnailWidth = this.$refs.thumbnailElement.naturalWidth;
+
+			if (thumbnailHeight < 80 || thumbnailWidth < 80) {
+				saveButtonRef.handleFailedSave();
+				return new Toast(
+					"Thumbnail width and height must be at least 80px."
+				);
+			}
+
+			if (thumbnailHeight > 4000 || thumbnailWidth > 4000) {
+				saveButtonRef.handleFailedSave();
+				return new Toast(
+					"Thumbnail width and height must be less than 4000px."
+				);
+			}
+
+			if (thumbnailHeight - thumbnailWidth > 5) {
+				saveButtonRef.handleFailedSave();
+				return new Toast("Thumbnail cannot be taller than it is wide.");
+			}
+
 			// Duration
 			if (
 				Number(song.skipDuration) + Number(song.duration) >

+ 2 - 2
frontend/src/components/modals/ManageStation/Tabs/Blacklist.vue

@@ -25,8 +25,8 @@
 				<div v-if="excludedPlaylists.length > 0">
 					<playlist-item
 						:playlist="playlist"
-						v-for="(playlist, index) in excludedPlaylists"
-						:key="'key-' + index"
+						v-for="playlist in excludedPlaylists"
+						:key="`key-${playlist._id}`"
 					>
 						<div class="icons-group" slot="actions">
 							<confirm @confirm="deselectPlaylist(playlist._id)">

+ 4 - 4
frontend/src/components/modals/ManageStation/Tabs/Playlists.vue

@@ -28,8 +28,8 @@
 			<div class="tab" v-show="tab === 'current'">
 				<div v-if="currentPlaylists.length > 0">
 					<playlist-item
-						v-for="(playlist, index) in currentPlaylists"
-						:key="'key-' + index"
+						v-for="playlist in currentPlaylists"
+						:key="`key-${playlist._id}`"
 						:playlist="playlist"
 						:show-owner="true"
 					>
@@ -106,8 +106,8 @@
 				</div>
 				<div v-if="search.results.length > 0">
 					<playlist-item
-						v-for="(playlist, index) in search.results"
-						:key="'searchKey-' + index"
+						v-for="playlist in search.results"
+						:key="`searchKey-${playlist._id}`"
 						:playlist="playlist"
 						:show-owner="true"
 					>

+ 3 - 3
frontend/src/components/modals/ManageStation/Tabs/Search.vue

@@ -21,8 +21,8 @@
 			</div>
 			<div v-if="musareSearch.results.length > 0">
 				<song-item
-					v-for="(song, index) in musareSearch.results"
-					:key="index + song._id"
+					v-for="song in musareSearch.results"
+					:key="song._id"
 					:song="song"
 				>
 					<div class="song-actions" slot="actions">
@@ -69,7 +69,7 @@
 			<div v-if="search.songs.results.length > 0" id="song-query-results">
 				<search-query-item
 					v-for="(result, index) in search.songs.results"
-					:key="index"
+					:key="result.id"
 					:result="result"
 				>
 					<div slot="actions">

+ 15 - 5
frontend/src/components/modals/RemoveAccount.vue

@@ -130,6 +130,7 @@
 				<div class="content-box-inputs">
 					<a
 						class="button is-github"
+						@click="relinkGithub()"
 						:href="`${apiDomain}/auth/github/link`"
 					>
 						<div class="icon">
@@ -154,7 +155,7 @@
 			>
 				<h2 class="content-box-title">Remove your account</h2>
 				<p class="content-box-description">
-					There is no going back after confirming account removal.
+					{{ accountRemovalMessage }}
 				</p>
 
 				<div class="content-box-inputs">
@@ -182,8 +183,10 @@ export default {
 	components: { Modal, Confirm },
 	data() {
 		return {
+			name: "RemoveAccount",
 			step: "confirm-identity",
 			apiDomain: "",
+			accountRemovalMessage: "",
 			password: {
 				value: "",
 				visible: false
@@ -197,6 +200,7 @@ export default {
 	}),
 	async mounted() {
 		this.apiDomain = await lofig.get("apiDomain");
+		this.accountRemovalMessage = await lofig.get("messages.accountRemoval");
 	},
 	methods: {
 		togglePasswordVisibility() {
@@ -227,14 +231,20 @@ export default {
 							`Your GitHub account isn't linked. Please re-link your account and try again.`
 						);
 						this.step = "relink-github";
-						localStorage.setItem(
-							"github_redirect",
-							window.location.pathname + window.location.search
-						);
 					}
 				} else new Toast(res.message);
 			});
 		},
+		relinkGithub() {
+			localStorage.setItem(
+				"github_redirect",
+				`${window.location.pathname + window.location.search}${
+					!this.$route.query.removeAccount
+						? "&removeAccount=relinked-github"
+						: ""
+				}`
+			);
+		},
 		remove() {
 			return this.socket.dispatch("users.remove", res => {
 				if (res.status === "success") {

+ 4 - 4
frontend/src/components/modals/Report.vue

@@ -84,15 +84,15 @@
 			<div class="edit-report-wrapper">
 				<div class="columns is-multiline">
 					<div
-						v-for="(issue, issueIndex) in issues"
+						v-for="issue in issues"
 						class="column is-half"
-						:key="issueIndex"
+						:key="issue.name"
 					>
 						<label class="label">{{ issue.name }}</label>
 						<p
-							v-for="(reason, reasonIndex) in issue.reasons"
+							v-for="reason in issue.reasons"
 							class="control"
-							:key="reasonIndex"
+							:key="reason"
 						>
 							<label class="checkbox">
 								<input

+ 1 - 1
frontend/src/components/modals/RequestSong.vue

@@ -42,7 +42,7 @@
 				>
 					<search-query-item
 						v-for="(result, index) in search.songs.results"
-						:key="index"
+						:key="result.id"
 						:result="result"
 					>
 						<div slot="actions">

+ 1 - 1
frontend/src/components/modals/ViewReport.vue

@@ -50,7 +50,7 @@
 					</tr>
 				</thead>
 				<tbody>
-					<tr v-for="(issue, index) in report.issues" :key="index">
+					<tr v-for="issue in report.issues" :key="issue.name">
 						<td>
 							<span>{{ issue.name }}</span>
 						</td>

+ 5 - 11
frontend/src/components/modals/WhatIsNew.vue

@@ -22,10 +22,7 @@
 						The features are so great
 					</div>
 					<ul class="sect-body">
-						<li
-							v-for="(feature, index) in news.features"
-							:key="index"
-						>
+						<li v-for="feature in news.features" :key="feature">
 							{{ feature }}
 						</li>
 					</ul>
@@ -34,8 +31,8 @@
 					<div class="sect-head-improvements">Improvements</div>
 					<ul class="sect-body">
 						<li
-							v-for="(improvement, index) in news.improvements"
-							:key="index"
+							v-for="improvement in news.improvements"
+							:key="improvement"
 						>
 							{{ improvement }}
 						</li>
@@ -44,7 +41,7 @@
 				<div v-show="news.bugs.length > 0" class="sect">
 					<div class="sect-head-bugs">Bugs Smashed</div>
 					<ul class="sect-body">
-						<li v-for="(bug, index) in news.bugs" :key="index">
+						<li v-for="bug in news.bugs" :key="bug">
 							{{ bug }}
 						</li>
 					</ul>
@@ -54,10 +51,7 @@
 						Coming Soon to a Musare near you
 					</div>
 					<ul class="sect-body">
-						<li
-							v-for="(upcoming, index) in news.upcoming"
-							:key="index"
-						>
+						<li v-for="upcoming in news.upcoming" :key="upcoming">
 							{{ upcoming }}
 						</li>
 					</ul>

+ 1 - 1
frontend/src/main.js

@@ -8,7 +8,7 @@ import store from "./store";
 
 import App from "./App.vue";
 
-const REQUIRED_CONFIG_VERSION = 2;
+const REQUIRED_CONFIG_VERSION = 3;
 
 const handleMetadata = attrs => {
 	document.title = `Musare | ${attrs.title}`;

+ 1 - 1
frontend/src/pages/Admin/tabs/HiddenSongs.vue

@@ -44,7 +44,7 @@
 				<tbody>
 					<tr
 						v-for="(song, index) in filteredSongs"
-						:key="index"
+						:key="song._id"
 						tabindex="0"
 						@keydown.up.prevent
 						@keydown.down.prevent

+ 5 - 5
frontend/src/pages/Admin/tabs/News.vue

@@ -15,7 +15,7 @@
 					</tr>
 				</thead>
 				<tbody>
-					<tr v-for="(news, index) in news" :key="index">
+					<tr v-for="news in news" :key="news._id">
 						<td>
 							<strong>{{ news.title }}</strong>
 						</td>
@@ -87,7 +87,7 @@
 								</p>
 								<span
 									v-for="(bug, index) in creating.bugs"
-									:key="index"
+									:key="bug"
 									class="tag is-info"
 								>
 									{{ bug }}
@@ -117,7 +117,7 @@
 								<span
 									v-for="(feature,
 									index) in creating.features"
-									:key="index"
+									:key="feature"
 									class="tag is-info"
 								>
 									{{ feature }}
@@ -150,7 +150,7 @@
 								<span
 									v-for="(improvement,
 									index) in creating.improvements"
-									:key="index"
+									:key="improvement"
 									class="tag is-info"
 								>
 									{{ improvement }}
@@ -182,7 +182,7 @@
 								<span
 									v-for="(upcoming,
 									index) in creating.upcoming"
-									:key="index"
+									:key="upcoming"
 									class="tag is-info"
 								>
 									{{ upcoming }}

+ 2 - 0
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -40,6 +40,7 @@
 						<td>Display name</td>
 						<td>Type</td>
 						<td>Is user modifiable</td>
+						<td>Privacy</td>
 						<td>Songs #</td>
 						<td>Playlist length</td>
 						<td>Created by</td>
@@ -54,6 +55,7 @@
 						<td>{{ playlist.displayName }}</td>
 						<td>{{ playlist.type }}</td>
 						<td>{{ playlist.isUserModifiable }}</td>
+						<td>{{ playlist.privacy }}</td>
 						<td>{{ playlist.songs.length }}</td>
 						<td>{{ totalLengthForPlaylist(playlist.songs) }}</td>
 						<td v-if="playlist.createdBy === 'Musare'">Musare</td>

+ 2 - 2
frontend/src/pages/Admin/tabs/Punishments.vue

@@ -14,8 +14,8 @@
 				</thead>
 				<tbody>
 					<tr
-						v-for="(punishment, index) in sortedPunishments"
-						:key="index"
+						v-for="punishment in sortedPunishments"
+						:key="punishment._id"
 					>
 						<td v-if="punishment.type === 'banUserId'">User ID</td>
 						<td v-else>IP Address</td>

+ 1 - 1
frontend/src/pages/Admin/tabs/Reports.vue

@@ -13,7 +13,7 @@
 					</tr>
 				</thead>
 				<tbody>
-					<tr v-for="(report, index) in reports" :key="index">
+					<tr v-for="report in reports" :key="report._id">
 						<td>
 							<span>
 								{{ report.song.youtubeId }}

+ 3 - 3
frontend/src/pages/Admin/tabs/Stations.vue

@@ -20,7 +20,7 @@
 					</tr>
 				</thead>
 				<tbody>
-					<tr v-for="(station, index) in stations" :key="index">
+					<tr v-for="station in stations" :key="station._id">
 						<td>
 							<span>{{ station._id }}</span>
 						</td>
@@ -124,7 +124,7 @@
 								</p>
 								<span
 									v-for="(genre, index) in newStation.genres"
-									:key="index"
+									:key="genre"
 									class="tag is-info"
 								>
 									{{ genre }}
@@ -153,7 +153,7 @@
 								<span
 									v-for="(genre,
 									index) in newStation.blacklistedGenres"
-									:key="index"
+									:key="genre"
 									class="tag is-info"
 								>
 									{{ genre }}

+ 2 - 2
frontend/src/pages/Admin/tabs/UnverifiedSongs.vue

@@ -43,8 +43,8 @@
 				</thead>
 				<tbody>
 					<tr
-						v-for="(song, index) in filteredSongs"
-						:key="index"
+						v-for="song in filteredSongs"
+						:key="song._id"
 						tabindex="0"
 						@keydown.up.prevent
 						@keydown.down.prevent

+ 58 - 1
frontend/src/pages/Admin/tabs/Users.vue

@@ -2,6 +2,40 @@
 	<div>
 		<metadata title="Admin | Users" />
 		<div class="container">
+			<h2 v-if="dataRequests.length > 0">Data Requests</h2>
+
+			<table class="table is-striped" v-if="dataRequests.length > 0">
+				<thead>
+					<tr>
+						<td>User ID</td>
+						<td>Request Type</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(request, index) in dataRequests" :key="index">
+						<td>{{ request.userId }}</td>
+						<td>
+							{{
+								request.type === "remove"
+									? "Remove all associated data"
+									: request.type
+							}}
+						</td>
+						<td>
+							<button
+								class="button is-primary"
+								@click="resolveDataRequest(request._id)"
+							>
+								Resolve
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+
+			<h2>Users</h2>
+
 			<table class="table is-striped">
 				<thead>
 					<tr>
@@ -20,7 +54,7 @@
 					</tr>
 				</thead>
 				<tbody>
-					<tr v-for="(user, index) in users" :key="index">
+					<tr v-for="user in users" :key="user._id">
 						<td>
 							<profile-picture
 								:avatar="user.avatar"
@@ -67,6 +101,7 @@
 
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
+import Toast from "toasters";
 
 import ProfilePicture from "@/components/ProfilePicture.vue";
 import ws from "@/ws";
@@ -79,6 +114,7 @@ export default {
 	data() {
 		return {
 			editingUserId: "",
+			dataRequests: [],
 			users: []
 		};
 	},
@@ -111,7 +147,28 @@ export default {
 					}
 				}
 			});
+
+			this.socket.dispatch("dataRequests.index", res => {
+				if (res.status === "success")
+					this.dataRequests = res.data.requests;
+			});
+
 			this.socket.dispatch("apis.joinAdminRoom", "users", () => {});
+
+			this.socket.on("event:admin.dataRequests.created", res =>
+				this.dataRequests.push(res.data.request)
+			);
+
+			this.socket.on("event:admin.dataRequests.resolved", res => {
+				this.dataRequests = this.dataRequests.filter(
+					request => request._id !== res.data.dataRequestId
+				);
+			});
+		},
+		resolveDataRequest(id) {
+			this.socket.dispatch("dataRequests.resolve", id, res => {
+				if (res.status === "success") new Toast(res.message);
+			});
 		},
 		...mapActions("modalVisibility", ["openModal"])
 	}

+ 1 - 1
frontend/src/pages/Admin/tabs/VerifiedSongs.vue

@@ -80,7 +80,7 @@
 					</tr>
 				</thead>
 				<tbody>
-					<tr v-for="(song, index) in filteredSongs" :key="index">
+					<tr v-for="(song, index) in filteredSongs" :key="song._id">
 						<td>
 							<img
 								class="song-thumbnail"

+ 7 - 7
frontend/src/pages/Home.vue

@@ -15,7 +15,7 @@
 						<img
 							class="logo"
 							src="/assets/white_wordmark.png"
-							:alt="`${this.siteName}` || `Musare`"
+							:alt="`${this.sitename}` || `Musare`"
 						/>
 						<div v-if="!loggedIn" class="buttons">
 							<button
@@ -54,8 +54,8 @@
 						:name="!drag ? 'draggable-list-transition' : null"
 					>
 						<router-link
-							v-for="(station, index) in favoriteStations"
-							:key="`key-${index}`"
+							v-for="station in favoriteStations"
+							:key="`key-${station._id}`"
 							:to="{
 								name: 'station',
 								params: { id: station.name }
@@ -280,8 +280,8 @@
 				</a>
 
 				<router-link
-					v-for="(station, index) in filteredStations"
-					:key="index"
+					v-for="station in filteredStations"
+					:key="station._id"
 					:to="{
 						name: 'station',
 						params: { id: station.name }
@@ -464,7 +464,7 @@ export default {
 			stations: [],
 			favoriteStations: [],
 			searchQuery: "",
-			siteName: "Musare",
+			sitename: "Musare",
 			orderOfFavoriteStations: [],
 			drag: false
 		};
@@ -512,7 +512,7 @@ export default {
 		}
 	},
 	async mounted() {
-		this.siteName = await lofig.get("siteSettings.siteName");
+		this.sitename = await lofig.get("siteSettings.sitename");
 
 		if (this.socket.readyState === 1) this.init();
 		ws.onConnect(() => this.init());

+ 9 - 15
frontend/src/pages/News.vue

@@ -5,8 +5,8 @@
 		<div class="container">
 			<div class="content-wrapper">
 				<div
-					v-for="(item, index) in news"
-					:key="index"
+					v-for="item in news"
+					:key="item._id"
 					class="card is-fullwidth"
 				>
 					<header class="card-header">
@@ -24,9 +24,8 @@
 							</div>
 							<ul class="sect-body">
 								<li
-									v-for="(feature,
-									itemIndex) in item.features"
-									:key="itemIndex"
+									v-for="feature in item.features"
+									:key="feature"
 								>
 									{{ feature }}
 								</li>
@@ -38,9 +37,8 @@
 							</div>
 							<ul class="sect-body">
 								<li
-									v-for="(improvement,
-									itemIndex) in item.improvements"
-									:key="itemIndex"
+									v-for="improvement in item.improvements"
+									:key="improvement"
 								>
 									{{ improvement }}
 								</li>
@@ -49,10 +47,7 @@
 						<div v-show="item.bugs.length > 0" class="sect">
 							<div class="sect-head-bugs">Bugs Smashed</div>
 							<ul class="sect-body">
-								<li
-									v-for="(bug, itemIndex) in item.bugs"
-									:key="itemIndex"
-								>
+								<li v-for="bug in item.bugs" :key="bug">
 									{{ bug }}
 								</li>
 							</ul>
@@ -63,9 +58,8 @@
 							</div>
 							<ul class="sect-body">
 								<li
-									v-for="(upcoming,
-									itemIndex) in item.upcoming"
-									:key="itemIndex"
+									v-for="upcoming in item.upcoming"
+									:key="upcoming"
 								>
 									{{ upcoming }}
 								</li>

+ 16 - 0
frontend/src/pages/Settings/tabs/Account.vue

@@ -171,6 +171,22 @@ export default {
 			}
 		}
 	},
+	mounted() {
+		if (
+			this.$route.query.removeAccount === "relinked-github" &&
+			!localStorage.getItem("github_redirect")
+		) {
+			this.openModal("removeAccount");
+
+			setTimeout(() => {
+				const modal = this.$parent.$children.find(
+					child => child.name === "RemoveAccount"
+				);
+
+				modal.confirmGithubLink();
+			}, 50);
+		}
+	},
 	methods: {
 		onInput(inputName) {
 			this.validation[inputName].entered = true;

+ 5 - 0
frontend/src/pages/Settings/tabs/Profile.vue

@@ -143,6 +143,11 @@ export default {
 			if (!validation.isLength(name, 1, 64))
 				return new Toast("Name must have between 1 and 64 characters.");
 
+			if (!validation.regex.name.test(name))
+				return new Toast(
+					"Invalid name format. Only letters, spaces, apostrophes and hyphens are allowed."
+				);
+
 			this.$refs.saveButton.status = "disabled";
 
 			return this.socket.dispatch(

+ 2 - 2
frontend/src/pages/Station/Sidebar/Playlists.vue

@@ -15,8 +15,8 @@
 			>
 				<playlist-item
 					:playlist="playlist"
-					v-for="(playlist, index) in playlists"
-					:key="'key-' + index"
+					v-for="playlist in playlists"
+					:key="`key-${playlist._id}`"
 					class="item-draggable"
 				>
 					<div class="icons-group" slot="actions">

+ 1 - 1
frontend/src/pages/Station/Sidebar/Users.vue

@@ -34,7 +34,7 @@
 
 		<aside class="menu">
 			<ul class="menu-list scrollable-list">
-				<li v-for="(user, index) in users.loggedIn" :key="index">
+				<li v-for="user in users.loggedIn" :key="user.username">
 					<router-link
 						:to="{
 							name: 'profile',

+ 10 - 12
frontend/src/pages/Team.vue

@@ -6,8 +6,8 @@
 			<h2 class="has-text-centered">Current Team</h2>
 			<div class="group">
 				<div
-					v-for="(member, index) in currentTeam"
-					:key="index"
+					v-for="member in currentTeam"
+					:key="member.name"
 					class="card"
 				>
 					<header class="card-header">
@@ -38,9 +38,8 @@
 						></div>
 						<div v-if="member.projects" class="projects">
 							<a
-								v-for="(project,
-								projectIndex) in member.projects"
-								:key="projectIndex"
+								v-for="project in member.projects"
+								:key="project"
 								:href="
 									'https://github.com/Musare/' +
 										project +
@@ -58,8 +57,8 @@
 			<h3 class="has-text-centered">Previous Team</h3>
 			<div class="group">
 				<div
-					v-for="(member, index) in previousTeam"
-					:key="index"
+					v-for="member in previousTeam"
+					:key="member.name"
 					class="card"
 				>
 					<header class="card-header">
@@ -90,9 +89,8 @@
 						></div>
 						<div v-if="member.projects" class="projects">
 							<a
-								v-for="(project,
-								projectIndex) in member.projects"
-								:key="projectIndex"
+								v-for="project in member.projects"
+								:key="project"
 								:href="
 									'https://github.com/Musare/' +
 										project +
@@ -111,8 +109,8 @@
 				<h4>Other Contributors</h4>
 				<div>
 					<a
-						v-for="(member, index) in otherContributors"
-						:key="index"
+						v-for="member in otherContributors"
+						:key="member.name"
 						:href="member.link"
 						target="_blank"
 					>

+ 1 - 0
frontend/src/validation.js

@@ -4,6 +4,7 @@ export default {
 		az09_: /^[a-z0-9_]+$/,
 		emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,
 		ascii: /^[\x00-\x7F]+$/,
+		name: /^[\p{L} .'-]+$/u,
 		password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~])[A-Za-z\d!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/,
 		custom: regex => {
 			return new RegExp(`^[${regex}]+$`);