瀏覽代碼

refactor: Started adding new websocket and job/event handling to frontend

Owen Diffey 1 年之前
父節點
當前提交
8d1947b0f8

+ 16 - 43
backend/src/JobContext.ts

@@ -3,10 +3,9 @@ import BaseModule from "./BaseModule";
 import Job from "./Job";
 import JobQueue from "./JobQueue";
 import { Log } from "./LogBook";
-import { SessionSchema } from "./models/schemas/session";
+import { SessionSchema } from "./models/schemas/sessions/schema";
 import { JobOptions } from "./types/JobOptions";
 import { Jobs, Modules } from "./types/Modules";
-import { UserSchema } from "./models/schemas/user";
 import { Models } from "./types/Models";
 
 export default class JobContext {
@@ -18,12 +17,6 @@ export default class JobContext {
 
 	private readonly _socketId?: string;
 
-	private _user?: UserSchema;
-
-	private _permissions?: Record<string, boolean>;
-
-	private _modelPermissions: Record<string, Record<string, boolean>>;
-
 	public constructor(
 		job: Job,
 		options?: { session?: SessionSchema; socketId?: string }
@@ -32,7 +25,6 @@ export default class JobContext {
 		this.jobQueue = JobQueue.getPrimaryInstance();
 		this._session = options?.session;
 		this._socketId = options?.socketId;
-		this._modelPermissions = {};
 	}
 
 	/**
@@ -92,19 +84,17 @@ export default class JobContext {
 		return this.executeJob("data", "getModel", model);
 	}
 
-	public async getUser(refresh = false) {
+	public async getUser() {
 		if (!this._session?.userId)
 			throw new Error("No user found for session");
 
-		if (this._user && !refresh) return this._user;
-
 		const User = await this.getModel("users");
 
-		this._user = await User.findById(this._session.userId);
+		const user = await User.findById(this._session.userId);
 
-		if (!this._user) throw new Error("No user found for session");
+		if (!user) throw new Error("No user found for session");
 
-		return this._user;
+		return user;
 	}
 
 	public async assertLoggedIn() {
@@ -112,38 +102,21 @@ export default class JobContext {
 			throw new Error("No user found for session");
 	}
 
-	public async getUserPermissions(refresh = false) {
-		if (this._permissions && !refresh) return this._permissions;
-
-		this._permissions = await this.executeJob(
-			"api",
-			"getUserPermissions",
-			{}
-		);
-
-		return this._permissions;
+	public async getUserPermissions() {
+		return this.executeJob("api", "getUserPermissions", {});
 	}
 
-	public async getUserModelPermissions(
-		{
+	public async getUserModelPermissions({
+		modelName,
+		modelId
+	}: {
+		modelName: keyof Models;
+		modelId?: Types.ObjectId;
+	}) {
+		return this.executeJob("api", "getUserModelPermissions", {
 			modelName,
 			modelId
-		}: {
-			modelName: keyof Models;
-			modelId?: Types.ObjectId;
-		},
-		refresh = false
-	) {
-		if (this._modelPermissions[modelName] && !refresh)
-			return this._modelPermissions[modelName];
-
-		this._modelPermissions[modelName] = await this.executeJob(
-			"api",
-			"getUserModelPermissions",
-			{ modelName, modelId }
-		);
-
-		return this._modelPermissions[modelName];
+		});
 	}
 
 	public async assertPermission(permission: string) {

+ 23 - 20
backend/src/modules/APIModule.ts

@@ -186,22 +186,14 @@ export default class APIModule extends BaseModule {
 					spotify: config.get("experimental.spotify")
 				}
 			},
-			user: user
-				? {
-						loggedIn: true,
-						role: user.role,
-						username: user.username,
-						email: user.email.address,
-						userId: user._id
-				  }
-				: { loggedIn: false }
+			user
 		};
 	}
 
 	public async getUserPermissions(context: JobContext) {
 		const user = await context.getUser().catch(() => null);
 
-		if (!user) return {};
+		if (!user) return permissions.guest;
 
 		const roles: UserRole[] = [user.role];
 
@@ -299,19 +291,27 @@ export default class APIModule extends BaseModule {
 	}
 
 	public async subscribe(context: JobContext, payload: { channel: string }) {
+		const socketId = context.getSocketId();
+
+		if (!socketId) throw new Error("No socketId specified");
+
 		const { channel } = payload;
-		const [, moduleName, modelName, modelId] =
-			/^([a-z]+)\.([a-z]+)\.([A-z0-9]+)\.?([A-z]+)?$/.exec(channel) ?? [];
+		const [, moduleName, modelName, event, modelId] =
+			/^([a-z]+)\.([a-z]+)\.([A-z]+)\.?([A-z0-9]+)?$/.exec(channel) ?? [];
 
-		if (moduleName === "model" && modelName && modelId)
-			await context.assertPermission(
-				`data.${modelName}.findById.${modelId}`
-			);
-		else await context.assertPermission(`event.${channel}`);
+		let permission = `event.${channel}`;
 
-		const socketId = context.getSocketId();
+		if (
+			moduleName === "model" &&
+			modelName &&
+			(modelId || event === "created")
+		) {
+			if (event === "created")
+				permission = `event.model.${modelName}.created`;
+			else permission = `data.${modelName}.findById.${modelId}`;
+		}
 
-		if (!socketId) throw new Error("No socketId specified");
+		await context.assertPermission(permission);
 
 		if (!this._subscriptions[channel])
 			this._subscriptions[channel] = new Set();
@@ -348,12 +348,15 @@ export default class APIModule extends BaseModule {
 
 		this._subscriptions[channel].delete(socketId);
 
-		if (this._subscriptions[channel].size === 0)
+		if (this._subscriptions[channel].size === 0) {
 			await context.executeJob("events", "unsubscribe", {
 				type: "event",
 				channel,
 				callback: value => this._subscriptionCallback(channel, value)
 			});
+
+			delete this._subscriptions[channel];
+		}
 	}
 
 	public async unsubscribeAll(context: JobContext) {

+ 6 - 2
backend/src/modules/DataModule.ts

@@ -321,11 +321,15 @@ export default class DataModule extends BaseModule {
 				patchEventEmitter.on(event, async ({ doc, oldDoc }) => {
 					const modelId = doc?._id ?? oldDoc?._id;
 
-					if (!modelId)
+					if (!modelId && action !== "created")
 						throw new Error(`Model Id not found for "${event}"`);
 
+					let channel = `model.${modelName}.${action}`;
+
+					if (action !== "created") channel += `.${modelId}`;
+
 					await this._jobQueue.runJob("events", "publish", {
-						channel: `model.${modelName}.${modelId}.${action}`,
+						channel,
 						value: { doc, oldDoc }
 					});
 				});

+ 3 - 1
backend/src/modules/EventsModule.ts

@@ -142,7 +142,9 @@ export default class EventsModule extends BaseModule {
 		else if (message.startsWith('"') && message.endsWith('"'))
 			message = message.substring(1).substring(0, message.length - 2);
 
-		await Promise.all(this._subscriptions[channel].map(cb => cb(message)));
+		await Promise.all(
+			this._subscriptions[channel].map(async cb => cb(message))
+		);
 	}
 
 	/**

+ 7 - 7
backend/src/modules/WebSocketModule.ts

@@ -143,7 +143,7 @@ export default class WebSocketModule extends BaseModule {
 			const [moduleName, ...jobNameParts] = moduleJob.split(".");
 			const jobName = jobNameParts.join(".");
 
-			callbackRef = (options ?? payload ?? {}).CB_REF;
+			const { callbackRef } = options ?? payload ?? {};
 
 			if (!callbackRef)
 				throw new Error(
@@ -164,21 +164,19 @@ export default class WebSocketModule extends BaseModule {
 				socketId: socket.getSocketId()
 			});
 
-			socket.dispatch("CB_REF", callbackRef, {
+			socket.dispatch("jobCallback", callbackRef, {
 				status: "success",
 				data: res
 			});
 		} catch (error) {
 			const message = error?.message ?? error;
 
-			socket.log({ type: "error", message });
-
 			if (callbackRef)
-				socket.dispatch("CB_REF", callbackRef, {
+				socket.dispatch("jobCallback", callbackRef, {
 					status: "error",
 					message
 				});
-			else socket.dispatch("ERROR", message);
+			else socket.dispatch("error", message);
 		}
 	}
 
@@ -233,7 +231,9 @@ export default class WebSocketModule extends BaseModule {
 
 		if (!socket) return;
 
-		socket.dispatch(channel, value);
+		const values = Array.isArray(value) ? value : [value];
+
+		socket.dispatch(channel, ...values);
 	}
 
 	/**

+ 8 - 3
backend/src/permissions.ts

@@ -2,13 +2,18 @@ import config from "config";
 import { UserRole } from "./models/schemas/users/UserRole";
 
 const temp = {
+	"data.stations.getData": true,
 	"data.news.getData": true,
+	"event.model.news.created": true,
+	"data.news.create": true,
 	"data.news.findById.*": true,
 	"data.news.updateById.*": true,
 	"data.news.deleteById.*": true
 };
 
-const user = { ...temp };
+const guest = { ...temp };
+
+const user = { ...guest };
 
 const dj = {
 	...user,
@@ -141,8 +146,8 @@ const admin = {
 };
 
 const permissions: Record<
-	UserRole | "owner" | "dj",
+	UserRole | "owner" | "dj" | "guest",
 	Record<string, boolean>
-> = { user, dj, owner, moderator, admin };
+> = { guest, user, dj, owner, moderator, admin };
 
 export default permissions;

+ 116 - 131
frontend/src/components/AdvancedTable.vue

@@ -14,14 +14,13 @@ import { useRoute, useRouter } from "vue-router";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { DraggableList } from "vue-draggable-list";
-import { useWebsocketsStore } from "@/stores/websockets";
+import { useWebsocketStore } from "@/stores/websocket";
 import { useModalsStore } from "@/stores/modals";
 import keyboardShortcuts from "@/keyboardShortcuts";
 import { useDragBox } from "@/composables/useDragBox";
 import {
 	TableColumn,
 	TableFilter,
-	TableEvents,
 	TableBulkActions
 } from "@/types/advancedTable";
 
@@ -59,12 +58,10 @@ const props = defineProps({
 		type: Array as PropType<TableFilter[]>,
 		default: () => []
 	},
-	dataAction: { type: String, default: null },
-	name: { type: String, default: null },
+	model: { type: String, required: true },
 	maxWidth: { type: Number, default: 1880 },
 	query: { type: Boolean, default: true },
 	keyboardShortcuts: { type: Boolean, default: true },
-	events: { type: Object as PropType<TableEvents>, default: () => {} },
 	bulkActions: {
 		type: Object as PropType<TableBulkActions>,
 		default: () => ({})
@@ -79,7 +76,7 @@ const router = useRouter();
 const modalsStore = useModalsStore();
 const { activeModals } = storeToRefs(modalsStore);
 
-const { socket } = useWebsocketsStore();
+const websocketStore = useWebsocketStore();
 
 const page = ref(1);
 const pageSize = ref(10);
@@ -180,6 +177,10 @@ const columnOrderChangedDebounceTimeout = ref();
 const lastSelectedItemIndex = ref(0);
 const bulkPopup = ref();
 const rowElements = ref([]);
+const subscriptions = ref({
+	updated: new Set(),
+	deleted: new Set()
+});
 
 const lastPage = computed(() => Math.ceil(count.value / pageSize.value));
 const sortedFilteredColumns = computed(() =>
@@ -203,30 +204,88 @@ const hasCheckboxes = computed(
 );
 const aModalIsOpen = computed(() => Object.keys(activeModals.value).length > 0);
 
-const getData = () => {
-	socket.dispatch(
-		props.dataAction,
-		page.value,
-		pageSize.value,
-		properties.value,
-		sort.value,
-		appliedFilters.value.map(filter => ({
+const unsubscribe = async (_subscriptions?) => {
+	_subscriptions = _subscriptions ?? subscriptions.value;
+
+	await Promise.allSettled(
+		Object.entries(_subscriptions).map(async ([event, modelIds]) => {
+			for await (const modelId of modelIds.values()) {
+				await websocketStore.unsubscribe(
+					`model.${props.model}.${event}.${modelId}`,
+					subscribe
+				);
+
+				subscriptions.value[event].delete(modelId);
+			}
+		})
+	);
+};
+
+const subscribe = async () => {
+	const previousSubscriptions = subscriptions.value;
+
+	await Promise.allSettled(
+		rows.value.map((row, index) =>
+			Object.entries(subscriptions.value).map(
+				async ([event, modelIds]) => {
+					if (modelIds.has(row._id)) {
+						previousSubscriptions[event].delete(row._id);
+						return;
+					}
+
+					await websocketStore.subscribe(
+						`model.${props.model}.${event}.${row._id}`,
+						({ doc }) => {
+							switch (event) {
+								case "updated":
+									rows.value[index] = {
+										...row,
+										...doc,
+										updated: true
+									};
+									break;
+								case "deleted":
+									rows.value[index] = {
+										...row,
+										selected: false,
+										removed: true
+									};
+									break;
+								default:
+									break;
+							}
+						}
+					);
+
+					subscriptions.value[event].add(row._id);
+				}
+			)
+		)
+	);
+
+	unsubscribe(previousSubscriptions);
+};
+
+const getData = async () => {
+	const data = await websocketStore.runJob(`data.${props.model}.getData`, {
+		page: page.value,
+		pageSize: pageSize.value,
+		properties: properties.value,
+		sort: sort.value,
+		queries: appliedFilters.value.map(filter => ({
 			...filter,
 			filterType: filter.filterType.name
 		})),
-		appliedFilterOperator.value,
-		res => {
-			if (res.status === "success") {
-				rows.value = res.data.data.map(row => ({
-					...row,
-					selected: false
-				}));
-				count.value = res.data.count;
-			} else {
-				new Toast(res.message);
-			}
-		}
-	);
+		operator: appliedFilterOperator.value
+	});
+
+	rows.value = data.data.map(row => ({
+		...row,
+		selected: false
+	}));
+	count.value = data.count;
+
+	return subscribe();
 };
 
 const setQuery = () => {
@@ -260,7 +319,7 @@ const setQuery = () => {
 
 const setLocalStorage = () => {
 	localStorage.setItem(
-		`advancedTableSettings:${props.name}`,
+		`advancedTableSettings:${props.model}`,
 		JSON.stringify({
 			pageSize: pageSize.value,
 			filter: {
@@ -709,7 +768,7 @@ const getTableSettings = () => {
 	}
 
 	const localStorageTableSettings = JSON.parse(
-		localStorage.getItem(`advancedTableSettings:${props.name}`)
+		localStorage.getItem(`advancedTableSettings:${props.model}`)
 	);
 
 	return {
@@ -743,22 +802,22 @@ const onWindowResize = () => {
 	}, 50);
 };
 
-const updateData = (index, data) => {
-	rows.value[index] = { ...rows.value[index], ...data, updated: true };
-};
-
-const removeData = index => {
-	rows.value[index] = {
-		...rows.value[index],
-		selected: false,
-		removed: true
-	};
-};
-
 onMounted(async () => {
 	const tableSettings = getTableSettings();
 
 	const columns = [
+		{
+			name: "updatedPlaceholder",
+			displayName: "",
+			properties: [],
+			sortable: false,
+			hidable: false,
+			draggable: false,
+			resizable: false,
+			minWidth: 5,
+			width: 5,
+			maxWidth: 5
+		},
 		...props.columns.map(column => ({
 			...(typeof props.columnDefault === "object"
 				? props.columnDefault
@@ -793,20 +852,6 @@ onMounted(async () => {
 			maxWidth: 47
 		});
 
-	if (props.events && props.events.updated)
-		columns.unshift({
-			name: "updatedPlaceholder",
-			displayName: "",
-			properties: [],
-			sortable: false,
-			hidable: false,
-			draggable: false,
-			resizable: false,
-			minWidth: 5,
-			width: 5,
-			maxWidth: 5
-		});
-
 	orderedColumns.value = columns.sort((columnA, columnB) => {
 		// Always places updatedPlaceholder column in the first position
 		if (columnA.name === "updatedPlaceholder") return -1;
@@ -901,83 +946,22 @@ onMounted(async () => {
 		}
 	}
 
-	socket.onConnect(() => {
-		getData();
+	await websocketStore.onReady(async () => {
+		await 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)
-		socket.on(`event:${props.events.updated.event}`, res => {
-			const index = rows.value
-				.map(row => row._id)
-				.indexOf(
-					props.events.updated.id
-						.split(".")
-						.reduce(
-							(previous, current) =>
-								previous &&
-								previous[current] !== null &&
-								previous[current] !== undefined
-									? previous[current]
-									: null,
-							res.data
-						)
-				);
-			const row = props.events.updated.item
-				.split(".")
-				.reduce(
-					(previous, current) =>
-						previous &&
-						previous[current] !== null &&
-						previous[current] !== undefined
-							? previous[current]
-							: null,
-					res.data
-				);
-			updateData(index, row);
-		});
-	if (props.events && props.events.removed)
-		socket.on(`event:${props.events.removed.event}`, res => {
-			const index = rows.value
-				.map(row => row._id)
-				.indexOf(
-					props.events.removed.id
-						.split(".")
-						.reduce(
-							(previous, current) =>
-								previous &&
-								previous[current] !== null &&
-								previous[current] !== undefined
-									? previous[current]
-									: null,
-							res.data
-						)
-				);
-			removeData(index);
-		});
+		await Promise.allSettled(
+			props.filters.map(async filter => {
+				if (filter.autosuggest && filter.autosuggestDataAction) {
+					const { items } = await websocketStore.runJob(
+						filter.autosuggestDataAction
+					);
+					autosuggest.value.allItems[filter.name] = items;
+				}
+			})
+		);
+	});
 
 	if (props.keyboardShortcuts) {
 		// Navigation section
@@ -1062,7 +1046,7 @@ onMounted(async () => {
 				// Reset local storage
 				if (aModalIsOpen.value) return;
 				console.log("Reset local storage");
-				localStorage.removeItem(`advancedTableSettings:${props.name}`);
+				localStorage.removeItem(`advancedTableSettings:${props.model}`);
 				router.push({ query: {} });
 			}
 		});
@@ -1128,6 +1112,7 @@ onMounted(async () => {
 });
 
 onUnmounted(() => {
+	unsubscribe();
 	window.removeEventListener("resize", onWindowResize);
 	if (storeTableSettingsDebounceTimeout.value)
 		clearTimeout(storeTableSettingsDebounceTimeout.value);

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

@@ -31,7 +31,7 @@ const configStore = useConfigStore();
 const { cookie, sitename, registrationDisabled, christmas } =
 	storeToRefs(configStore);
 
-const { loggedIn, username } = storeToRefs(userAuthStore);
+const { loggedIn, currentUser } = storeToRefs(userAuthStore);
 const { logout, hasPermission } = userAuthStore;
 const userPreferencesStore = useUserPreferencesStore();
 const { nightmode } = storeToRefs(userPreferencesStore);
@@ -136,7 +136,7 @@ onMounted(async () => {
 					class="nav-item"
 					:to="{
 						name: 'profile',
-						params: { username },
+						params: { username: currentUser.username },
 						query: { tab: 'playlists' }
 					}"
 				>
@@ -146,7 +146,7 @@ onMounted(async () => {
 					class="nav-item"
 					:to="{
 						name: 'profile',
-						params: { username }
+						params: { username: currentUser.username }
 					}"
 				>
 					Profile

+ 27 - 32
frontend/src/components/modals/EditNews.vue

@@ -4,9 +4,7 @@ import { marked } from "marked";
 import DOMPurify from "dompurify";
 import Toast from "toasters";
 import { formatDistance } from "date-fns";
-import { GetNewsResponse } from "@musare_types/actions/NewsActions";
-import { GenericResponse } from "@musare_types/actions/GenericActions";
-import { useWebsocketsStore } from "@/stores/websockets";
+import { useWebsocketStore } from "@/stores/websocket";
 import { useModalsStore } from "@/stores/modals";
 import { useForm } from "@/composables/useForm";
 
@@ -25,7 +23,7 @@ const props = defineProps({
 	sector: { type: String, default: "admin" }
 });
 
-const { socket } = useWebsocketsStore();
+const { onReady, runJob } = useWebsocketStore();
 
 const { closeCurrentModal } = useModalsStore();
 
@@ -70,19 +68,19 @@ const { inputs, save, setOriginalValue } = useForm(
 	},
 	({ status, messages, values }, resolve, reject) => {
 		if (status === "success") {
-			const data = {
+			const query = {
 				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);
+
+			runJob(`data.news.${props.createNews ? "create" : "updateById"}`, {
+				_id: props.createNews ? null : props.newsId,
+				query
+			})
+				.then(resolve)
+				.catch(reject);
 		} else {
 			if (status === "unchanged") new Toast(messages.unchanged);
 			else if (status === "error")
@@ -97,7 +95,7 @@ const { inputs, save, setOriginalValue } = useForm(
 	}
 );
 
-onMounted(() => {
+onMounted(async () => {
 	marked.use({
 		renderer: {
 			table(header, body) {
@@ -109,26 +107,23 @@ onMounted(() => {
 		}
 	});
 
-	socket.onConnect(() => {
+	await onReady(async () => {
 		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();
-					}
-				}
-			);
+			const data = await runJob(`data.news.findById`, {
+				_id: props.newsId
+			}).catch(() => {
+				new Toast("News with that ID not found.");
+				closeCurrentModal();
+			});
+
+			setOriginalValue({
+				markdown: data.markdown,
+				status: data.status,
+				showToNewUsers: data.showToNewUsers
+			});
+
+			createdBy.value = data.createdBy;
+			createdAt.value = data.createdAt;
 		}
 	});
 });

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

@@ -14,7 +14,7 @@ const props = defineProps({
 });
 
 const userAuthStore = useUserAuthStore();
-const { loggedIn, userId } = storeToRefs(userAuthStore);
+const { loggedIn, currentUser } = storeToRefs(userAuthStore);
 const { hasPermission } = userAuthStore;
 
 const { socket } = useWebsocketsStore();
@@ -25,7 +25,7 @@ const { playlist } = storeToRefs(editPlaylistStore);
 const { preventCloseUnsaved } = useModalsStore();
 
 const isOwner = () =>
-	loggedIn.value && userId.value === playlist.value.createdBy;
+	loggedIn.value && currentUser.value._id === playlist.value.createdBy;
 
 const isEditable = permission =>
 	((playlist.value.type === "user" ||

+ 5 - 4
frontend/src/components/modals/EditPlaylist/index.vue

@@ -43,7 +43,7 @@ const stationStore = useStationStore();
 const userAuthStore = useUserAuthStore();
 
 const { station } = storeToRefs(stationStore);
-const { loggedIn, userId, role: userRole } = storeToRefs(userAuthStore);
+const { loggedIn, currentUser } = storeToRefs(userAuthStore);
 
 const drag = ref(false);
 const gettingSongs = ref(false);
@@ -85,7 +85,7 @@ const showTab = payload => {
 const { hasPermission } = userAuthStore;
 
 const isOwner = () =>
-	loggedIn.value && userId.value === playlist.value.createdBy;
+	loggedIn.value && currentUser.value._id === playlist.value.createdBy;
 
 const isEditable = permission =>
 	((playlist.value.type === "user" ||
@@ -461,9 +461,10 @@ onBeforeUnmount(() => {
 													'user' ||
 													(station.requests.access ===
 														'owner' &&
-														(userRole === 'admin' ||
+														(currentUser.role ===
+															'admin' ||
 															station.owner ===
-																userId))) &&
+																currentUser._id))) &&
 												(element.mediaSource.split(
 													':'
 												)[0] !== 'soundcloud' ||

+ 5 - 3
frontend/src/components/modals/ManageStation/index.vue

@@ -39,7 +39,7 @@ const props = defineProps({
 const tabs = ref([]);
 
 const userAuthStore = useUserAuthStore();
-const { loggedIn, userId } = storeToRefs(userAuthStore);
+const { loggedIn, currentUser } = storeToRefs(userAuthStore);
 
 const { socket } = useWebsocketsStore();
 
@@ -358,14 +358,16 @@ onMounted(() => {
 
 		socket.on("event:manageStation.djs.added", res => {
 			if (res.data.stationId === stationId.value) {
-				if (res.data.user._id === userId.value) updatePermissions();
+				if (res.data.user._id === currentUser.value._id)
+					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();
+				if (res.data.user._id === currentUser.value._id)
+					updatePermissions();
 				removeDj(res.data.user);
 			}
 		});

+ 6 - 4
frontend/src/composables/useSortablePlaylists.ts

@@ -14,7 +14,7 @@ export const useSortablePlaylists = () => {
 	const userAuthStore = useUserAuthStore();
 	const userPlaylistsStore = useUserPlaylistsStore();
 
-	const { userId: myUserId } = storeToRefs(userAuthStore);
+	const { currentUser } = storeToRefs(userAuthStore);
 
 	const playlists = computed({
 		get: () => userPlaylistsStore.playlists,
@@ -22,7 +22,9 @@ export const useSortablePlaylists = () => {
 			userPlaylistsStore.updatePlaylists(playlists);
 		}
 	});
-	const isCurrentUser = computed(() => userId.value === myUserId.value);
+	const isCurrentUser = computed(
+		() => userId.value === currentUser.value?._id
+	);
 
 	const { socket } = useWebsocketsStore();
 
@@ -58,7 +60,7 @@ export const useSortablePlaylists = () => {
 	onMounted(async () => {
 		await nextTick();
 
-		if (!userId.value) userId.value = myUserId.value;
+		if (!userId.value) userId.value = currentUser.value?._id;
 
 		socket.onConnect(() => {
 			if (!isCurrentUser.value)
@@ -205,7 +207,7 @@ export const useSortablePlaylists = () => {
 		isCurrentUser,
 		playlists,
 		orderOfPlaylists,
-		myUserId,
+		currentUser,
 		savePlaylistOrder,
 		calculatePlaylistOrder
 	};

+ 139 - 165
frontend/src/main.ts

@@ -15,6 +15,7 @@ import ms from "@/ms";
 import i18n from "@/i18n";
 
 import AppComponent from "./App.vue";
+import { useWebsocketStore } from "./stores/websocket";
 
 const handleMetadata = attrs => {
 	const configStore = useConfigStore();
@@ -78,6 +79,16 @@ app.directive("focus", {
 	}
 });
 
+app.use(createPinia());
+
+const configStore = useConfigStore();
+const modalsStore = useModalsStore();
+const userAuthStore = useUserAuthStore();
+const ws = useWebsocketStore();
+const { createSocket } = useWebsocketsStore();
+
+createSocket();
+
 const router = createRouter({
 	history: createWebHistory(),
 	routes: [
@@ -257,173 +268,136 @@ const router = createRouter({
 	]
 });
 
-app.use(createPinia());
-
-const { createSocket } = useWebsocketsStore();
-createSocket().then(async socket => {
-	const configStore = useConfigStore();
-	const userAuthStore = useUserAuthStore();
-	const modalsStore = useModalsStore();
-
-	router.beforeEach((to, from, next) => {
-		if (window.stationInterval) {
-			clearInterval(window.stationInterval);
-			window.stationInterval = 0;
-		}
-
-		// if (to.name === "station") {
-		// 	modalsStore.closeModal("manageStation");
-		// }
-
-		modalsStore.closeAllModals();
-
-		if (socket.ready && to.fullPath !== from.fullPath) {
-			socket.clearCallbacks();
-			socket.destroyListeners();
-		}
-
-		if (to.query.toast) {
-			const toast =
-				typeof to.query.toast === "string"
-					? { content: to.query.toast, timeout: 20000 }
-					: { ...to.query.toast };
-			new Toast(toast);
-			const { query } = to;
-			delete query.toast;
-			next({ ...to, query });
-		} else if (
-			to.meta.configRequired ||
-			to.meta.loginRequired ||
-			to.meta.permissionRequired ||
-			to.meta.guestsOnly
-		) {
-			const gotData = () => {
-				if (
-					to.meta.configRequired &&
-					!configStore.get(`${to.meta.configRequired}`)
-				)
-					next({ path: "/" });
-				else if (to.meta.loginRequired && !userAuthStore.loggedIn)
-					next({ path: "/login" });
-				else if (
-					to.meta.permissionRequired &&
-					!userAuthStore.hasPermission(
-						`${to.meta.permissionRequired}`
-					)
-				) {
-					if (
-						to.path.startsWith("/admin") &&
-						to.path !== "/admin/songs"
-					)
-						next({ path: "/admin/songs" });
-					else next({ path: "/" });
-				} else if (to.meta.guestsOnly && userAuthStore.loggedIn)
-					next({ path: "/" });
-				else next();
-			};
-
-			if (userAuthStore.gotData && userAuthStore.gotPermissions)
-				gotData();
-			else {
-				const unsubscribe = userAuthStore.$onAction(
-					({ name, after, onError }) => {
-						if (
-							name === "authData" ||
-							name === "updatePermissions"
-						) {
-							after(() => {
-								if (
-									userAuthStore.gotData &&
-									userAuthStore.gotPermissions
-								)
-									gotData();
-								unsubscribe();
-							});
-
-							onError(() => {
-								unsubscribe();
-							});
-						}
-					}
-				);
-			}
-		} else next();
-	});
-
-	app.use(router);
-
-	socket.on("ready", res => {
-		const { loggedIn, role, username, userId, email } = res.user;
-
-		userAuthStore.authData({
-			loggedIn,
-			role,
-			username,
-			email,
-			userId
-		});
-
-		if (loggedIn) {
-			userAuthStore.resetCookieExpiration();
-		}
-
-		if (configStore.experimental.media_session) ms.initialize();
-		else ms.uninitialize();
-	});
-
-	socket.on("keep.event:user.banned", res =>
-		userAuthStore.banUser(res.data.ban)
-	);
-
-	socket.on("keep.event:user.username.updated", res =>
-		userAuthStore.updateUsername(res.data.username)
-	);
-
-	socket.on("keep.event:user.preferences.updated", res => {
-		const { preferences } = res.data;
-
-		const {
-			changeAutoSkipDisliked,
-			changeNightmode,
-			changeActivityLogPublic,
-			changeAnonymousSongRequests,
-			changeActivityWatch
-		} = useUserPreferencesStore();
-
-		if (preferences.autoSkipDisliked !== undefined)
-			changeAutoSkipDisliked(preferences.autoSkipDisliked);
-
-		if (preferences.nightmode !== undefined) {
-			changeNightmode(preferences.nightmode);
-		}
-
-		if (preferences.activityLogPublic !== undefined)
-			changeActivityLogPublic(preferences.activityLogPublic);
-
-		if (preferences.anonymousSongRequests !== undefined)
-			changeAnonymousSongRequests(preferences.anonymousSongRequests);
-
-		if (preferences.activityWatch !== undefined)
-			changeActivityWatch(preferences.activityWatch);
-	});
+router.beforeEach((to, from, next) => {
+	if (window.stationInterval) {
+		clearInterval(window.stationInterval);
+		window.stationInterval = 0;
+	}
 
-	socket.on("keep.event:user.role.updated", res => {
-		userAuthStore.updateRole(res.data.role);
-		userAuthStore.updatePermissions().then(() => {
-			const { meta } = router.currentRoute.value;
+	// if (to.name === "station") {
+	// 	modalsStore.closeModal("manageStation");
+	// }
+
+	modalsStore.closeAllModals();
+
+	// if (socket.ready && to.fullPath !== from.fullPath) {
+	// 	socket.clearCallbacks();
+	// 	socket.destroyListeners();
+	// }
+
+	if (to.query.toast) {
+		const toast =
+			typeof to.query.toast === "string"
+				? { content: to.query.toast, timeout: 20000 }
+				: { ...to.query.toast };
+		new Toast(toast);
+		const { query } = to;
+		delete query.toast;
+		next({ ...to, query });
+	} else if (
+		to.meta.configRequired ||
+		to.meta.loginRequired ||
+		to.meta.permissionRequired ||
+		to.meta.guestsOnly
+	) {
+		const gotData = () => {
 			if (
-				meta &&
-				meta.permissionRequired &&
-				!userAuthStore.hasPermission(`${meta.permissionRequired}`)
+				to.meta.configRequired &&
+				!configStore.get(`${to.meta.configRequired}`)
 			)
-				router.push({
-					path: "/",
-					query: {
-						toast: "You no longer have access to the page you were viewing."
-					}
-				});
-		});
-	});
+				next({ path: "/" });
+			else if (to.meta.loginRequired && !userAuthStore.loggedIn)
+				next({ path: "/login" });
+			else if (
+				to.meta.permissionRequired &&
+				!userAuthStore.hasPermission(`${to.meta.permissionRequired}`)
+			) {
+				if (to.path.startsWith("/admin") && to.path !== "/admin/songs")
+					next({ path: "/admin/songs" });
+				else next({ path: "/" });
+			} else if (to.meta.guestsOnly && userAuthStore.loggedIn)
+				next({ path: "/" });
+			else next();
+		};
 
-	app.mount("#root");
+		if (userAuthStore.gotData && userAuthStore.gotPermissions) gotData();
+		else {
+			const unsubscribe = userAuthStore.$onAction(
+				({ name, after, onError }) => {
+					if (name === "updatePermissions") {
+						after(() => {
+							if (
+								userAuthStore.gotData &&
+								userAuthStore.gotPermissions
+							)
+								gotData();
+							unsubscribe();
+						});
+
+						onError(() => {
+							unsubscribe();
+						});
+					}
+				}
+			);
+		}
+	} else next();
 });
+
+app.use(router);
+
+app.mount("#root");
+
+// socket.on("keep.event:user.banned", res =>
+// 	userAuthStore.banUser(res.data.ban)
+// );
+
+// socket.on("keep.event:user.username.updated", res =>
+// 	userAuthStore.updateUsername(res.data.username)
+// );
+
+// socket.on("keep.event:user.preferences.updated", res => {
+// 	const { preferences } = res.data;
+
+// 	const {
+// 		changeAutoSkipDisliked,
+// 		changeNightmode,
+// 		changeActivityLogPublic,
+// 		changeAnonymousSongRequests,
+// 		changeActivityWatch
+// 	} = useUserPreferencesStore();
+
+// 	if (preferences.autoSkipDisliked !== undefined)
+// 		changeAutoSkipDisliked(preferences.autoSkipDisliked);
+
+// 	if (preferences.nightmode !== undefined) {
+// 		changeNightmode(preferences.nightmode);
+// 	}
+
+// 	if (preferences.activityLogPublic !== undefined)
+// 		changeActivityLogPublic(preferences.activityLogPublic);
+
+// 	if (preferences.anonymousSongRequests !== undefined)
+// 		changeAnonymousSongRequests(preferences.anonymousSongRequests);
+
+// 	if (preferences.activityWatch !== undefined)
+// 		changeActivityWatch(preferences.activityWatch);
+// });
+
+// socket.on("keep.event:user.role.updated", res => {
+// 	userAuthStore.updateRole(res.data.role);
+// 	userAuthStore.updatePermissions().then(() => {
+// 		const { meta } = router.currentRoute.value;
+// 		if (
+// 			meta &&
+// 			meta.permissionRequired &&
+// 			!userAuthStore.hasPermission(`${meta.permissionRequired}`)
+// 		)
+// 			router.push({
+// 				path: "/",
+// 				query: {
+// 					toast: "You no longer have access to the page you were viewing."
+// 				}
+// 			});
+// 	});
+// });

+ 7 - 24
frontend/src/pages/Admin/News.vue

@@ -2,10 +2,10 @@
 import { defineAsyncComponent, ref } from "vue";
 import Toast from "toasters";
 import { GenericResponse } from "@musare_types/actions/GenericActions";
-import { useWebsocketsStore } from "@/stores/websockets";
+import { useWebsocketStore } from "@/stores/websocket";
 import { useModalsStore } from "@/stores/modals";
 import { useUserAuthStore } from "@/stores/userAuth";
-import { TableColumn, TableFilter, TableEvents } from "@/types/advancedTable";
+import { TableColumn, TableFilter } from "@/types/advancedTable";
 
 const AdvancedTable = defineAsyncComponent(
 	() => import("@/components/AdvancedTable.vue")
@@ -17,7 +17,7 @@ const UserLink = defineAsyncComponent(
 	() => import("@/components/UserLink.vue")
 );
 
-const { socket } = useWebsocketsStore();
+const { runJob } = useWebsocketStore();
 
 const columnDefault = ref<TableColumn>({
 	sortable: true,
@@ -110,29 +110,14 @@ const filters = ref<TableFilter[]>([
 		defaultFilterType: "contains"
 	}
 ]);
-const events = ref<TableEvents>({
-	adminRoom: "news",
-	updated: {
-		event: "admin.news.updated",
-		id: "news._id",
-		item: "news"
-	},
-	removed: {
-		event: "admin.news.deleted",
-		id: "newsId"
-	}
-});
 
 const { openModal } = useModalsStore();
 
 const { hasPermission } = useUserAuthStore();
 
-const remove = (id: string) => {
-	socket.dispatch(
-		"news.remove",
-		id,
-		(res: GenericResponse) => new Toast(res.message)
-	);
+const remove = async (_id: string) => {
+	const res = await runJob(`data.news.deleteById`, { _id });
+	new Toast(res.message);
 };
 </script>
 
@@ -163,10 +148,8 @@ const remove = (id: string) => {
 			:column-default="columnDefault"
 			:columns="columns"
 			:filters="filters"
-			data-action="news.getData"
-			name="admin-news"
+			model="news"
 			:max-width="1200"
-			:events="events"
 		>
 			<template #column-options="slotProps">
 				<div class="row-options">

+ 2 - 16
frontend/src/pages/Admin/Stations.vue

@@ -4,7 +4,7 @@ import Toast from "toasters";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import { useUserAuthStore } from "@/stores/userAuth";
-import { TableColumn, TableFilter, TableEvents } from "@/types/advancedTable";
+import { TableColumn, TableFilter } from "@/types/advancedTable";
 
 const AdvancedTable = defineAsyncComponent(
 	() => import("@/components/AdvancedTable.vue")
@@ -287,18 +287,6 @@ const filters = ref<TableFilter[]>([
 		]
 	}
 ]);
-const events = ref<TableEvents>({
-	adminRoom: "stations",
-	updated: {
-		event: "station.updated",
-		id: "station._id",
-		item: "station"
-	},
-	removed: {
-		event: "admin.station.deleted",
-		id: "stationId"
-	}
-});
 const jobs = ref([]);
 if (hasPermission("stations.clearEveryStationQueue"))
 	jobs.value.push({
@@ -345,9 +333,7 @@ const remove = stationId => {
 			:column-default="columnDefault"
 			:columns="columns"
 			:filters="filters"
-			data-action="stations.getData"
-			name="admin-stations"
-			:events="events"
+			model="stations"
 		>
 			<template #column-options="slotProps">
 				<div class="row-options">

+ 8 - 5
frontend/src/pages/Home.vue

@@ -39,7 +39,7 @@ const route = useRoute();
 const router = useRouter();
 
 const { sitename, registrationDisabled } = storeToRefs(configStore);
-const { loggedIn, userId } = storeToRefs(userAuthStore);
+const { loggedIn, currentUser } = storeToRefs(userAuthStore);
 const { hasPermission } = userAuthStore;
 
 const { socket } = useWebsocketsStore();
@@ -49,9 +49,10 @@ const searchQuery = ref("");
 const orderOfFavoriteStations = ref([]);
 const handledLoginRegisterRedirect = ref(false);
 
-const isOwner = station => loggedIn.value && station.owner === userId.value;
+const isOwner = station =>
+	loggedIn.value && station.owner === currentUser.value;
 const isDj = station =>
-	loggedIn.value && !!station.djs.find(dj => dj === userId.value);
+	loggedIn.value && !!station.djs.find(dj => dj === currentUser.value._id);
 const isOwnerOrDj = station => isOwner(station) || isDj(station);
 
 const isPlaying = station => typeof station.currentSong.title !== "undefined";
@@ -315,11 +316,13 @@ onMounted(async () => {
 	});
 
 	socket.on("event:station.djs.added", res => {
-		if (res.data.user._id === userId.value) fetchStations();
+		if (loggedIn.value && res.data.user._id === currentUser.value._id)
+			fetchStations();
 	});
 
 	socket.on("event:station.djs.removed", res => {
-		if (res.data.user._id === userId.value) fetchStations();
+		if (loggedIn.value && res.data.user._id === currentUser.value._id)
+			fetchStations();
 	});
 
 	socket.on("keep.event:user.role.updated", () => {

+ 36 - 38
frontend/src/pages/News.vue

@@ -11,7 +11,7 @@ import {
 	NewsRemovedResponse
 } from "@musare_types/events/NewsEvents";
 import { GetPublishedNewsResponse } from "@musare_types/actions/NewsActions";
-import { useWebsocketsStore } from "@/stores/websockets";
+import { useWebsocketStore } from "@/stores/websocket";
 
 const MainHeader = defineAsyncComponent(
 	() => import("@/components/MainHeader.vue")
@@ -23,13 +23,13 @@ const UserLink = defineAsyncComponent(
 	() => import("@/components/UserLink.vue")
 );
 
-const { socket } = useWebsocketsStore();
+const { onReady, runJob, subscribe } = useWebsocketStore();
 
 const news = ref<NewsModel[]>([]);
 
 const { sanitize } = DOMPurify;
 
-onMounted(() => {
+onMounted(async () => {
 	marked.use({
 		renderer: {
 			table(header, body) {
@@ -41,41 +41,35 @@ onMounted(() => {
 		}
 	});
 
-	socket.onConnect(() => {
-		socket.dispatch(
-			"news.getPublished",
-			(res: GetPublishedNewsResponse) => {
-				if (res.status === "success") news.value = res.data.news;
-			}
-		);
-
-		socket.dispatch("apis.joinRoom", "news");
-	});
-
-	socket.on("event:news.created", (res: NewsCreatedResponse) =>
-		news.value.unshift(res.data.news)
-	);
+	await onReady(async () => {
+		news.value = await runJob("data.news.newest", {});
 
-	socket.on("event:news.updated", (res: NewsUpdatedResponse) => {
-		if (res.data.news.status === "draft") {
-			news.value = news.value.filter(
-				item => item._id !== res.data.news._id
-			);
-			return;
-		}
-
-		for (let n = 0; n < news.value.length; n += 1) {
-			if (news.value[n]._id === res.data.news._id)
-				news.value[n] = {
-					...news.value[n],
-					...res.data.news
-				};
-		}
+		await subscribe("model.news.created", ({ doc }) => {
+			news.value.unshift(doc);
+		});
 	});
 
-	socket.on("event:news.deleted", (res: NewsRemovedResponse) => {
-		news.value = news.value.filter(item => item._id !== res.data.newsId);
-	});
+	// TODO: Subscribe to loaded model updated/deleted events
+	// socket.on("event:news.updated", (res: NewsUpdatedResponse) => {
+	// 	if (res.data.news.status === "draft") {
+	// 		news.value = news.value.filter(
+	// 			item => item._id !== res.data.news._id
+	// 		);
+	// 		return;
+	// 	}
+
+	// 	for (let n = 0; n < news.value.length; n += 1) {
+	// 		if (news.value[n]._id === res.data.news._id)
+	// 			news.value[n] = {
+	// 				...news.value[n],
+	// 				...res.data.news
+	// 			};
+	// 	}
+	// });
+
+	// socket.on("event:news.deleted", (res: NewsRemovedResponse) => {
+	// 	news.value = news.value.filter(item => item._id !== res.data.newsId);
+	// });
 });
 </script>
 
@@ -102,9 +96,13 @@ onMounted(() => {
 							:title="new Date(item.createdAt).toString()"
 						>
 							{{
-								formatDistance(item.createdAt, new Date(), {
-									addSuffix: true
-								})
+								formatDistance(
+									new Date(item.createdAt),
+									new Date(),
+									{
+										addSuffix: true
+									}
+								)
 							}}
 						</span>
 					</div>

+ 6 - 4
frontend/src/pages/Profile/Tabs/RecentActivity.vue

@@ -33,7 +33,7 @@ const offsettedFromNextSet = ref(0);
 const isGettingSet = ref(false);
 
 const userAuthStore = useUserAuthStore();
-const { userId: myUserId } = storeToRefs(userAuthStore);
+const { currentUser } = storeToRefs(userAuthStore);
 const { getBasicUser } = userAuthStore;
 
 const hideActivity = activityId => {
@@ -79,7 +79,7 @@ onMounted(() => {
 	document.body.addEventListener("scroll", handleScroll);
 
 	socket.onConnect(() => {
-		if (myUserId.value !== props.userId)
+		if (currentUser.value?._id !== props.userId)
 			getBasicUser(props.userId).then(user => {
 				if (user && user.username) username.value = user.username;
 			});
@@ -131,7 +131,9 @@ onUnmounted(() => {
 
 			<p class="section-description">
 				This is a log of all actions
-				{{ userId === myUserId ? "you have" : `${username} has` }}
+				{{
+					userId === currentUser?._id ? "you have" : `${username} has`
+				}}
 				taken recently
 			</p>
 
@@ -146,7 +148,7 @@ onUnmounted(() => {
 				>
 					<template #actions>
 						<quick-confirm
-							v-if="userId === myUserId"
+							v-if="userId === currentUser?._id"
 							@confirm="hideActivity(activity._id)"
 						>
 							<a content="Hide Activity" v-tippy>

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

@@ -32,7 +32,7 @@ const userId = ref("");
 const isUser = ref(false);
 
 const userAuthStore = useUserAuthStore();
-const { userId: myUserId } = storeToRefs(userAuthStore);
+const { currentUser } = storeToRefs(userAuthStore);
 const { hasPermission } = userAuthStore;
 
 onMounted(() => {
@@ -106,7 +106,8 @@ onMounted(() => {
 				<div
 					class="buttons"
 					v-if="
-						myUserId === userId || hasPermission('admin.view.users')
+						currentUser?._id === userId ||
+						hasPermission('admin.view.users')
 					"
 				>
 					<router-link
@@ -119,7 +120,7 @@ onMounted(() => {
 					<router-link
 						to="/settings"
 						class="button is-primary"
-						v-if="myUserId === userId"
+						v-if="currentUser?._id === userId"
 					>
 						Settings
 					</router-link>

+ 2 - 2
frontend/src/pages/ResetPassword.vue

@@ -21,7 +21,7 @@ const props = defineProps({
 });
 
 const userAuthStore = useUserAuthStore();
-const { email: accountEmail } = storeToRefs(userAuthStore);
+const { currentUser } = storeToRefs(userAuthStore);
 
 const { socket } = useWebsocketsStore();
 
@@ -193,7 +193,7 @@ watch(
 );
 
 onMounted(() => {
-	inputs.value.email.value = accountEmail.value;
+	inputs.value.email.value = currentUser.value?.email;
 });
 </script>
 

+ 19 - 14
frontend/src/pages/Settings/Tabs/Account.vue

@@ -27,7 +27,7 @@ const { socket } = useWebsocketsStore();
 
 const saveButton = ref();
 
-const { userId } = storeToRefs(userAuthStore);
+const { currentUser } = storeToRefs(userAuthStore);
 const { originalUser, modifiedUser } = settingsStore;
 
 const validation = reactive({
@@ -63,21 +63,26 @@ const changeEmail = () => {
 
 	saveButton.value.saveStatus = "disabled";
 
-	return socket.dispatch("users.updateEmail", userId.value, email, res => {
-		if (res.status !== "success") {
-			new Toast(res.message);
-			saveButton.value.handleFailedSave();
-		} else {
-			new Toast("Successfully changed email address");
+	return socket.dispatch(
+		"users.updateEmail",
+		currentUser.value?._id,
+		email,
+		res => {
+			if (res.status !== "success") {
+				new Toast(res.message);
+				saveButton.value.handleFailedSave();
+			} else {
+				new Toast("Successfully changed email address");
 
-			updateOriginalUser({
-				property: "email.address",
-				value: email
-			});
+				updateOriginalUser({
+					property: "email.address",
+					value: email
+				});
 
-			saveButton.value.handleSuccessfulSave();
+				saveButton.value.handleSuccessfulSave();
+			}
 		}
-	});
+	);
 };
 
 const changeUsername = () => {
@@ -100,7 +105,7 @@ const changeUsername = () => {
 
 	return socket.dispatch(
 		"users.updateUsername",
-		userId.value,
+		currentUser.value?._id,
 		username,
 		res => {
 			if (res.status !== "success") {

+ 53 - 38
frontend/src/pages/Settings/Tabs/Profile.vue

@@ -21,7 +21,7 @@ const { socket } = useWebsocketsStore();
 
 const saveButton = ref();
 
-const { userId } = storeToRefs(userAuthStore);
+const { currentUser } = storeToRefs(userAuthStore);
 const { originalUser, modifiedUser } = settingsStore;
 
 const { updateOriginalUser } = settingsStore;
@@ -44,21 +44,26 @@ const changeName = () => {
 
 	saveButton.value.status = "disabled";
 
-	return socket.dispatch("users.updateName", userId.value, name, res => {
-		if (res.status !== "success") {
-			new Toast(res.message);
-			saveButton.value.handleFailedSave();
-		} else {
-			new Toast("Successfully changed name");
+	return socket.dispatch(
+		"users.updateName",
+		currentUser.value?._id,
+		name,
+		res => {
+			if (res.status !== "success") {
+				new Toast(res.message);
+				saveButton.value.handleFailedSave();
+			} else {
+				new Toast("Successfully changed name");
 
-			updateOriginalUser({
-				property: "name",
-				value: name
-			});
+				updateOriginalUser({
+					property: "name",
+					value: name
+				});
 
-			saveButton.value.handleSuccessfulSave();
+				saveButton.value.handleSuccessfulSave();
+			}
 		}
-	});
+	);
 };
 
 const changeLocation = () => {
@@ -71,7 +76,7 @@ const changeLocation = () => {
 
 	return socket.dispatch(
 		"users.updateLocation",
-		userId.value,
+		currentUser.value?._id,
 		location,
 		res => {
 			if (res.status !== "success") {
@@ -99,21 +104,26 @@ const changeBio = () => {
 
 	saveButton.value.status = "disabled";
 
-	return socket.dispatch("users.updateBio", userId.value, bio, res => {
-		if (res.status !== "success") {
-			new Toast(res.message);
-			saveButton.value.handleFailedSave();
-		} else {
-			new Toast("Successfully changed bio");
+	return socket.dispatch(
+		"users.updateBio",
+		currentUser.value?._id,
+		bio,
+		res => {
+			if (res.status !== "success") {
+				new Toast(res.message);
+				saveButton.value.handleFailedSave();
+			} else {
+				new Toast("Successfully changed bio");
 
-			updateOriginalUser({
-				property: "bio",
-				value: bio
-			});
+				updateOriginalUser({
+					property: "bio",
+					value: bio
+				});
 
-			saveButton.value.handleSuccessfulSave();
+				saveButton.value.handleSuccessfulSave();
+			}
 		}
-	});
+	);
 };
 
 const changeAvatar = () => {
@@ -121,21 +131,26 @@ const changeAvatar = () => {
 
 	saveButton.value.status = "disabled";
 
-	return socket.dispatch("users.updateAvatar", userId.value, avatar, res => {
-		if (res.status !== "success") {
-			new Toast(res.message);
-			saveButton.value.handleFailedSave();
-		} else {
-			new Toast("Successfully updated avatar");
+	return socket.dispatch(
+		"users.updateAvatar",
+		currentUser.value?._id,
+		avatar,
+		res => {
+			if (res.status !== "success") {
+				new Toast(res.message);
+				saveButton.value.handleFailedSave();
+			} else {
+				new Toast("Successfully updated avatar");
 
-			updateOriginalUser({
-				property: "avatar",
-				value: avatar
-			});
+				updateOriginalUser({
+					property: "avatar",
+					value: avatar
+				});
 
-			saveButton.value.handleSuccessfulSave();
+				saveButton.value.handleSuccessfulSave();
+			}
 		}
-	});
+	);
 };
 
 const saveChanges = () => {

+ 2 - 2
frontend/src/pages/Settings/Tabs/Security.vue

@@ -41,7 +41,7 @@ const newPassword = ref();
 const oldPassword = ref();
 
 const { isPasswordLinked, isGithubLinked } = settingsStore;
-const { userId } = storeToRefs(userAuthStore);
+const { currentUser } = storeToRefs(userAuthStore);
 
 const togglePasswordVisibility = refName => {
 	const ref = refName === "oldPassword" ? oldPassword : newPassword;
@@ -91,7 +91,7 @@ const unlinkGitHub = () => {
 	});
 };
 const removeSessions = () => {
-	socket.dispatch(`users.removeSessions`, userId.value, res => {
+	socket.dispatch(`users.removeSessions`, currentUser.value?._id, res => {
 		new Toast(res.message);
 	});
 };

+ 5 - 5
frontend/src/pages/Station/index.vue

@@ -130,7 +130,7 @@ const { activeModals } = storeToRefs(modalsStore);
 // TODO fix this if it still has some use, as this is no longer accurate
 // const video = computed(() => store.state.modals.editSong);
 
-const { loggedIn, userId } = storeToRefs(userAuthStore);
+const { loggedIn, currentUser } = storeToRefs(userAuthStore);
 const { nightmode, autoSkipDisliked } = storeToRefs(userPreferencesStore);
 const {
 	station,
@@ -173,7 +173,7 @@ const aModalIsOpen = computed(() => Object.keys(activeModals.value).length > 0);
 const currentUserQueueSongs = computed(
 	() =>
 		songsList.value.filter(
-			queueSong => queueSong.requestedBy === userId.value
+			queueSong => queueSong.requestedBy === currentUser.value?._id
 		).length
 );
 
@@ -1642,7 +1642,7 @@ onMounted(async () => {
 					: currentSong.value.skipVotes - 1,
 				skipVotesCurrent: null,
 				voted:
-					res.data.userId === userId.value
+					res.data.userId === currentUser.value?._id
 						? res.data.voted
 						: currentSong.value.voted
 			});
@@ -1708,7 +1708,7 @@ onMounted(async () => {
 	});
 
 	socket.on("event:station.djs.added", res => {
-		if (res.data.user._id === userId.value)
+		if (res.data.user._id === currentUser.value?._id)
 			updatePermissions().then(() => {
 				if (
 					!hasPermission("stations.view") &&
@@ -1725,7 +1725,7 @@ onMounted(async () => {
 	});
 
 	socket.on("event:station.djs.removed", res => {
-		if (res.data.user._id === userId.value)
+		if (res.data.user._id === currentUser.value?._id)
 			updatePermissions().then(() => {
 				if (
 					!hasPermission("stations.view") &&

+ 245 - 318
frontend/src/stores/userAuth.ts

@@ -1,330 +1,257 @@
 import { defineStore } from "pinia";
 import Toast from "toasters";
+import { computed, ref } from "vue";
 import validation from "@/validation";
-import { useWebsocketsStore } from "@/stores/websockets";
+import { useWebsocketStore } from "@/stores/websocket";
 import { useConfigStore } from "@/stores/config";
+import { User } from "@/types/user";
 
-export const useUserAuthStore = defineStore("userAuth", {
-	state: (): {
-		userIdMap: Record<string, { name: string; username: string }>;
-		userIdRequested: Record<string, boolean>;
-		pendingUserIdCallbacks: Record<
+export const useUserAuthStore = defineStore("userAuth", () => {
+	const configStore = useConfigStore();
+	const websocketStore = useWebsocketStore();
+
+	const userIdMap = ref<Record<string, { name: string; username: string }>>(
+		{}
+	);
+	const userIdRequested = ref<Record<string, boolean>>({});
+	const pendingUserIdCallbacks = ref<
+		Record<
 			string,
 			((basicUser: { name: string; username: string }) => void)[]
-		>;
-		loggedIn: boolean;
-		role: "user" | "moderator" | "admin";
+		>
+	>({});
+	const currentUser = ref<User | null>();
+	const banned = ref(false);
+	const ban = ref<{
+		reason?: string;
+		expiresAt?: number;
+	} | null>({
+		reason: null,
+		expiresAt: null
+	});
+	const gotData = ref(false);
+	const gotPermissions = ref(false);
+	const permissions = ref<Record<string, boolean>>({});
+
+	const loggedIn = computed(() => !!currentUser.value);
+
+	const register = async (user: {
 		username: string;
 		email: string;
+		password: string;
+		recaptchaToken: string;
+	}) => {
+		const { username, email, password, recaptchaToken } = user;
+
+		if (!email || !username || !password)
+			throw new Error("Please fill in all fields");
+
+		if (!validation.isLength(email, 3, 254))
+			throw new Error("Email must have between 3 and 254 characters.");
+
+		if (
+			email.indexOf("@") !== email.lastIndexOf("@") ||
+			!validation.regex.emailSimple.test(email)
+		)
+			throw new Error("Invalid email format.");
+
+		if (!validation.isLength(username, 2, 32))
+			throw new Error("Username must have between 2 and 32 characters.");
+
+		if (!validation.regex.azAZ09_.test(username))
+			throw new Error(
+				"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _."
+			);
+
+		if (username.replaceAll(/[_]/g, "").length === 0)
+			throw new Error(
+				"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _, and there has to be at least one letter or number."
+			);
+
+		if (!validation.isLength(password, 6, 200))
+			throw new Error("Password must have between 6 and 200 characters.");
+
+		if (!validation.regex.password.test(password))
+			throw new Error(
+				"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character."
+			);
+
+		const data = await websocketStore.runJob("users.register", {
+			username,
+			email,
+			password,
+			recaptchaToken
+		});
+
+		if (!data?.SID) throw new Error("You must login");
+
+		const date = new Date();
+		date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
+
+		const secure = configStore.urls.secure ? "secure=true; " : "";
+
+		let domain = "";
+		if (configStore.urls.host !== "localhost")
+			domain = ` domain=${configStore.urls.host};`;
+
+		document.cookie = `${configStore.cookie}=${
+			data.SID
+		}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
+	};
+
+	const login = async (user: { email: string; password: string }) => {
+		const { email, password } = user;
+
+		const data = await websocketStore.runJob("users.login", {
+			email,
+			password
+		});
+
+		const date = new Date();
+		date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
+
+		const secure = configStore.urls.secure ? "secure=true; " : "";
+
+		let domain = "";
+		if (configStore.urls.host !== "localhost")
+			domain = ` domain=${configStore.urls.host};`;
+
+		document.cookie = `${configStore.cookie}=${
+			data.SID
+		}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
+
+		const bc = new BroadcastChannel(`${configStore.cookie}.user_login`);
+		bc.postMessage(true);
+		bc.close();
+	};
+
+	const logout = async () => {
+		await websocketStore.runJob("users.logout", {});
+
+		document.cookie = `${configStore.cookie}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
+
+		window.location.reload();
+	};
+
+	const mapUserId = (data: {
 		userId: string;
-		banned: boolean;
-		ban: {
-			reason: string;
-			expiresAt: number;
-		};
-		gotData: boolean;
-		gotPermissions: boolean;
-		permissions: Record<string, boolean>;
-	} => ({
-		userIdMap: {},
-		userIdRequested: {},
-		pendingUserIdCallbacks: {},
-		loggedIn: false,
-		role: "",
-		username: "",
-		email: "",
-		userId: "",
-		banned: false,
-		ban: {
-			reason: null,
-			expiresAt: null
-		},
-		gotData: false,
-		gotPermissions: false,
-		permissions: {}
-	}),
-	actions: {
-		register(user: {
-			username: string;
-			email: string;
-			password: string;
-			recaptchaToken: string;
-		}) {
-			return new Promise((resolve, reject) => {
-				const { username, email, password, recaptchaToken } = user;
-
-				if (!email || !username || !password)
-					reject(new Error("Please fill in all fields"));
-				else if (!validation.isLength(email, 3, 254))
-					reject(
-						new Error(
-							"Email must have between 3 and 254 characters."
-						)
-					);
-				else if (
-					email.indexOf("@") !== email.lastIndexOf("@") ||
-					!validation.regex.emailSimple.test(email)
-				)
-					reject(new Error("Invalid email format."));
-				else if (!validation.isLength(username, 2, 32))
-					reject(
-						new Error(
-							"Username must have between 2 and 32 characters."
-						)
-					);
-				else if (!validation.regex.azAZ09_.test(username))
-					reject(
-						new Error(
-							"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _."
-						)
-					);
-				else if (username.replaceAll(/[_]/g, "").length === 0)
-					reject(
-						new Error(
-							"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _, and there has to be at least one letter or number."
-						)
-					);
-				else if (!validation.isLength(password, 6, 200))
-					reject(
-						new Error(
-							"Password must have between 6 and 200 characters."
-						)
-					);
-				else if (!validation.regex.password.test(password))
-					reject(
-						new Error(
-							"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character."
-						)
-					);
-				else {
-					const { socket } = useWebsocketsStore();
-					const configStore = useConfigStore();
-					socket.dispatch(
-						"users.register",
-						username,
-						email,
-						password,
-						recaptchaToken,
-						res => {
-							if (res.status === "success") {
-								if (res.SID) {
-									const date = new Date();
-									date.setTime(
-										new Date().getTime() +
-											2 * 365 * 24 * 60 * 60 * 1000
-									);
-
-									const secure = configStore.urls.secure
-										? "secure=true; "
-										: "";
-
-									let domain = "";
-									if (configStore.urls.host !== "localhost")
-										domain = ` domain=${configStore.urls.host};`;
-
-									document.cookie = `${configStore.cookie}=${
-										res.SID
-									}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
-
-									return resolve({
-										status: "success",
-										message: "Account registered!"
-									});
-								}
-
-								return reject(new Error("You must login"));
-							}
-
-							return reject(new Error(res.message));
-						}
+		user: { name: string; username: string };
+	}) => {
+		userIdMap.value[`Z${data.userId}`] = data.user;
+		userIdRequested.value[`Z${data.userId}`] = false;
+	};
+
+	const requestingUserId = (userId: string) => {
+		userIdRequested.value[`Z${userId}`] = true;
+		if (!pendingUserIdCallbacks.value[`Z${userId}`])
+			pendingUserIdCallbacks.value[`Z${userId}`] = [];
+	};
+
+	const pendingUser = (
+		userId: string,
+		callback: (basicUser: { name: string; username: string }) => void
+	) => {
+		pendingUserIdCallbacks.value[`Z${userId}`].push(callback);
+	};
+
+	const clearPendingCallbacks = (userId: string) => {
+		pendingUserIdCallbacks.value[`Z${userId}`] = [];
+	};
+
+	const getBasicUser = async (userId: string) =>
+		new Promise((resolve, reject) => {
+			if (typeof userIdMap.value[`Z${userId}`] === "string") {
+				resolve(userIdMap.value[`Z${userId}`]);
+				return;
+			}
+
+			if (userIdRequested.value[`Z${userId}`] === true) {
+				pendingUser(userId, user => resolve(user));
+				return;
+			}
+
+			requestingUserId(userId);
+
+			websocketStore
+				.runJob("users.getBasicUser", { _id: userId })
+				.then(user => {
+					mapUserId({
+						userId,
+						user
+					});
+
+					pendingUserIdCallbacks.value[`Z${userId}`].forEach(cb =>
+						cb(user)
 					);
-				}
-			});
-		},
-		login(user: { email: string; password: string }) {
-			return new Promise((resolve, reject) => {
-				const { email, password } = user;
-
-				const { socket } = useWebsocketsStore();
-				const configStore = useConfigStore();
-				socket.dispatch("users.login", email, password, res => {
-					if (res.status === "success") {
-						const date = new Date();
-						date.setTime(
-							new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000
-						);
-
-						const secure = configStore.urls.secure
-							? "secure=true; "
-							: "";
-
-						let domain = "";
-						if (configStore.urls.host !== "localhost")
-							domain = ` domain=${configStore.urls.host};`;
-
-						document.cookie = `${configStore.cookie}=${
-							res.data.SID
-						}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
-
-						const bc = new BroadcastChannel(
-							`${configStore.cookie}.user_login`
-						);
-						bc.postMessage(true);
-						bc.close();
-
-						return resolve({
-							status: "success",
-							message: "Logged in!"
-						});
-					}
-
-					return reject(new Error(res.message));
-				});
-			});
-		},
-		logout() {
-			return new Promise((resolve, reject) => {
-				const { socket } = useWebsocketsStore();
-				socket.dispatch("users.logout", res => {
-					if (res.status === "success") {
-						const configStore = useConfigStore();
-						document.cookie = `${configStore.cookie}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
-						window.location.reload();
-						return resolve(true);
-					}
-					new Toast(res.message);
-					return reject(new Error(res.message));
-				});
-			});
-		},
-		getBasicUser(userId: string) {
-			return new Promise(
-				(
-					resolve: (
-						basicUser: { name: string; username: string } | null
-					) => void
-				) => {
-					if (typeof this.userIdMap[`Z${userId}`] !== "string") {
-						if (this.userIdRequested[`Z${userId}`] !== true) {
-							this.requestingUserId(userId);
-							const { socket } = useWebsocketsStore();
-							socket.dispatch(
-								"users.getBasicUser",
-								userId,
-								res => {
-									if (res.status === "success") {
-										const user = res.data;
-
-										this.mapUserId({
-											userId,
-											user: {
-												name: user.name,
-												username: user.username
-											}
-										});
-
-										this.pendingUserIdCallbacks[
-											`Z${userId}`
-										].forEach(cb => cb(user));
-
-										this.clearPendingCallbacks(userId);
-
-										return resolve(user);
-									}
-									return resolve(null);
-								}
-							);
-						} else {
-							this.pendingUser(userId, user => resolve(user));
-						}
-					} else {
-						resolve(this.userIdMap[`Z${userId}`]);
-					}
-				}
-			);
-		},
-		mapUserId(data: {
-			userId: string;
-			user: { name: string; username: string };
-		}) {
-			this.userIdMap[`Z${data.userId}`] = data.user;
-			this.userIdRequested[`Z${data.userId}`] = false;
-		},
-		requestingUserId(userId: string) {
-			this.userIdRequested[`Z${userId}`] = true;
-			if (!this.pendingUserIdCallbacks[`Z${userId}`])
-				this.pendingUserIdCallbacks[`Z${userId}`] = [];
-		},
-		pendingUser(
-			userId: string,
-			callback: (basicUser: { name: string; username: string }) => void
-		) {
-			this.pendingUserIdCallbacks[`Z${userId}`].push(callback);
-		},
-		clearPendingCallbacks(userId: string) {
-			this.pendingUserIdCallbacks[`Z${userId}`] = [];
-		},
-		authData(data: {
-			loggedIn: boolean;
-			role: string;
-			username: string;
-			email: string;
-			userId: string;
-		}) {
-			this.loggedIn = data.loggedIn;
-			this.role = data.role;
-			this.username = data.username;
-			this.email = data.email;
-			this.userId = data.userId;
-			this.gotData = true;
-		},
-		banUser(ban: { reason: string; expiresAt: number }) {
-			this.banned = true;
-			this.ban = ban;
-		},
-		updateUsername(username: string) {
-			this.username = username;
-		},
-		updateRole(role: string) {
-			this.role = role;
-		},
-		hasPermission(permission: string) {
-			return !!(this.permissions && this.permissions[permission]);
-		},
-		updatePermissions() {
-			return new Promise(resolve => {
-				const { socket } = useWebsocketsStore();
-				socket.dispatch("utils.getPermissions", res => {
-					this.permissions = res.data.permissions;
-					this.gotPermissions = true;
-					resolve(this.permissions);
-				});
-			});
-		},
-		resetCookieExpiration() {
-			const cookies = {};
-			document.cookie.split("; ").forEach(cookie => {
-				cookies[cookie.substring(0, cookie.indexOf("="))] =
-					cookie.substring(cookie.indexOf("=") + 1, cookie.length);
-			});
-
-			const configStore = useConfigStore();
-			const SIDName = configStore.cookie;
-
-			if (!cookies[SIDName]) return;
-
-			const date = new Date();
-			date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
-
-			const secure = configStore.urls.secure ? "secure=true; " : "";
-
-			let domain = "";
-			if (configStore.urls.host !== "localhost")
-				domain = ` domain=${configStore.urls.host};`;
-
-			document.cookie = `${configStore.cookie}=${
-				cookies[SIDName]
-			}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
-		}
-	}
+
+					clearPendingCallbacks(userId);
+
+					resolve(user);
+				})
+				.catch(reject);
+		});
+
+	const banUser = (data: { reason: string; expiresAt: number }) => {
+		banned.value = true;
+		ban.value = data;
+	};
+
+	const hasPermission = (permission: string) =>
+		!!(permissions.value && permissions.value[permission]);
+
+	const updatePermissions = () =>
+		websocketStore.runJob("api.getUserPermissions", {}).then(data => {
+			permissions.value = data;
+			gotPermissions.value = true;
+		});
+
+	const resetCookieExpiration = () => {
+		const cookies = {};
+		document.cookie.split("; ").forEach(cookie => {
+			cookies[cookie.substring(0, cookie.indexOf("="))] =
+				cookie.substring(cookie.indexOf("=") + 1, cookie.length);
+		});
+
+		const SIDName = configStore.cookie;
+
+		if (!cookies[SIDName]) return;
+
+		const date = new Date();
+		date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
+
+		const secure = configStore.urls.secure ? "secure=true; " : "";
+
+		let domain = "";
+		if (configStore.urls.host !== "localhost")
+			domain = ` domain=${configStore.urls.host};`;
+
+		document.cookie = `${configStore.cookie}=${
+			cookies[SIDName]
+		}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
+	};
+
+	return {
+		userIdMap,
+		userIdRequested,
+		pendingUserIdCallbacks,
+		currentUser,
+		banned,
+		ban,
+		gotData,
+		gotPermissions,
+		permissions,
+		loggedIn,
+		register,
+		login,
+		logout,
+		mapUserId,
+		requestingUserId,
+		pendingUser,
+		clearPendingCallbacks,
+		getBasicUser,
+		banUser,
+		hasPermission,
+		updatePermissions,
+		resetCookieExpiration
+	};
 });

+ 169 - 0
frontend/src/stores/websocket.ts

@@ -0,0 +1,169 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+import { useConfigStore } from "./config";
+import { useUserAuthStore } from "./userAuth";
+import utils from "@/utils";
+import ms from "@/ms";
+
+export const useWebsocketStore = defineStore("websocket", () => {
+	const configStore = useConfigStore();
+	const userAuthStore = useUserAuthStore();
+
+	const socket = ref();
+	const ready = ref(false);
+	const readyCallbacks = ref(new Set());
+	const jobCallbacks = ref({});
+	const pendingJobs = ref([]);
+	const subscriptions = ref({});
+
+	const socketChannels = ["ready", "error"];
+
+	const runJob = async (job: string, payload?: any) =>
+		new Promise((resolve, reject) => {
+			const callbackRef = utils.guid();
+			const message = JSON.stringify([
+				job,
+				payload ?? {},
+				{ callbackRef }
+			]);
+
+			jobCallbacks.value[callbackRef] = { resolve, reject };
+
+			if (ready.value && socket.value?.readyState === WebSocket.OPEN)
+				socket.value.send(message);
+			else pendingJobs.value.push(message);
+		});
+
+	const subscribe = async (
+		channel: string,
+		callback: (data?: any) => any
+	) => {
+		if (!socketChannels.includes(channel))
+			await runJob("api.subscribe", { channel });
+
+		if (!subscriptions.value[channel])
+			subscriptions.value[channel] = new Set();
+
+		subscriptions.value[channel].add(callback);
+	};
+
+	const unsubscribe = async (
+		channel: string,
+		callback: (data?: any) => any
+	) => {
+		if (!socketChannels.includes(channel))
+			await runJob("api.unsubscribe", { channel });
+
+		if (!subscriptions.value[channel]) return;
+
+		subscriptions.value[channel].delete(callback);
+	};
+
+	const unsubscribeAll = async () => {
+		await runJob("api.unsubscribeAll");
+
+		subscriptions.value = {};
+	};
+
+	const onReady = async (callback: () => any) => {
+		readyCallbacks.value.add(callback);
+		if (ready.value) await callback();
+	};
+
+	const removeReadyCallback = (callback: () => any) => {
+		readyCallbacks.value.delete(callback);
+	};
+
+	subscribe("ready", async data => {
+		configStore.$patch(data.config);
+
+		userAuthStore.currentUser = data.user;
+		userAuthStore.gotData = true;
+
+		if (userAuthStore.loggedIn) {
+			userAuthStore.resetCookieExpiration();
+		}
+
+		if (configStore.experimental.media_session) ms.initialize();
+		else ms.uninitialize();
+
+		ready.value = true;
+
+		await userAuthStore.updatePermissions();
+
+		for await (const callback of readyCallbacks.value.values()) {
+			await callback().catch(() => {}); // TODO: Error handling
+		}
+
+		await Promise.allSettled(
+			Object.keys(subscriptions.value)
+				.filter(channel => !socketChannels.includes(channel))
+				.map(channel => runJob("api.subscribe", { channel }))
+		);
+
+		pendingJobs.value.forEach(message => socket.value.send(message));
+	});
+
+	const init = () => {
+		if (
+			[WebSocket.CONNECTING, WebSocket.OPEN].includes(
+				socket.value?.readyState
+			)
+		)
+			socket.value.close();
+
+		socket.value = new WebSocket(configStore.urls.ws);
+
+		socket.value.addEventListener("message", async message => {
+			const data = JSON.parse(message.data);
+			const name = data.shift(0);
+
+			if (name === "jobCallback") {
+				const callbackRef = data.shift(0);
+				const response = data.shift(0);
+
+				if (response?.status === "success")
+					jobCallbacks.value[callbackRef]?.resolve(response?.data);
+				else jobCallbacks.value[callbackRef]?.reject(response);
+
+				delete jobCallbacks.value[callbackRef];
+
+				return;
+			}
+
+			if (!subscriptions.value[name]) return;
+
+			for await (const subscription of subscriptions.value[
+				name
+			].values()) {
+				await subscription(...data);
+			}
+		});
+
+		socket.value.addEventListener("close", () => {
+			// TODO: fix this not going away after reconnect
+
+			ready.value = false;
+
+			// try to reconnect every 1000ms, if the user isn't banned
+			if (!userAuthStore.banned) setTimeout(init, 1000);
+		});
+	};
+
+	init();
+
+	return {
+		socket,
+		ready,
+		readyCallbacks,
+		jobCallbacks,
+		pendingJobs,
+		subscriptions,
+		runJob,
+		subscribe,
+		unsubscribe,
+		unsubscribeAll,
+		onReady,
+		removeReadyCallback
+	};
+});