Browse Source

feat: Starts updating registration functionality

Owen Diffey 1 week ago
parent
commit
4b5d2b65a5

+ 68 - 0
backend/src/modules/DataModule/models/User/jobs/Register.ts

@@ -0,0 +1,68 @@
+import Joi from "joi";
+import User from "@models/User";
+import bcrypt from "bcrypt";
+import sha256 from "sha256";
+import isLoggedOut from "@/modules/DataModule/permissions/isLoggedOut";
+import DataModuleJob from "@/modules/DataModule/DataModuleJob";
+import { UserRole } from "@models/User/UserRole";
+import { UserAvatarType } from "../UserAvatarType";
+import { UserAvatarColor } from "../UserAvatarColor";
+
+// TODO: Disable registration if configured as such
+export default class Register extends DataModuleJob {
+	protected static _model = User;
+
+	protected static _hasPermission = isLoggedOut;
+
+	protected static _payloadSchema = Joi.object({
+		query: Joi.object({
+			username: Joi.string().min(2).max(32).regex(/^_*[a-zA-Z0-9][a-zA-Z0-9_]*$/).required()
+				.external(async (username: string) => {
+					const user = await User.findOne({
+						where: { username }
+					});
+
+					if (user instanceof User)
+						throw new Error("A user with that username already exists.");
+				}, "unique"),
+			// TODO: Whitelist regex
+			emailAddress: Joi.string().email().min(3).max(254).required()
+				.external(async (emailAddress: string) => {
+					const user = await User.findOne({
+						where: { emailAddress }
+					});
+
+					if (user instanceof User)
+						throw new Error("A user with that email already exists.");
+				}, "unique"),
+			password: Joi.string().min(6).max(200).required(), // TODO: format validation
+		}).required()
+	});
+
+	protected async _execute() {
+		const { query } = this._payload;
+
+		const user = new User();
+		user.name = query.username;
+		user.username = query.username;
+		user.emailAddress = query.emailAddress;
+		user.password = await bcrypt.hash(
+			sha256(query.password),
+			await bcrypt.genSalt(10)
+		);
+		user.role = UserRole.USER;
+		user.avatarType = UserAvatarType.INITIALS;
+		user.avatarColor = Object.values(UserAvatarColor)[
+			Math.floor(
+				Math.random() * Object.values(UserAvatarColor).length
+			)
+		];
+		await user.save();
+
+		const session = await user.createSessionModel();
+
+		return {
+			sessionId: session._id
+		};
+	}
+}

+ 101 - 118
frontend/src/components/modals/Register.vue

@@ -1,70 +1,39 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref, watch, onMounted } from "vue";
-import { useRoute } from "vue-router";
+import { defineAsyncComponent, ref, onMounted } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useModalsStore } from "@/stores/modals";
 import validation from "@/validation";
+import { useEvents } from "@/composables/useEvents";
+import { useWebsocketStore } from "@/stores/websocket";
+import { useForm } from "@/composables/useForm";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 const InputHelpBox = defineAsyncComponent(
 	() => import("@/components/InputHelpBox.vue")
 );
 
-const route = useRoute();
-
-const username = ref({
-	value: "",
-	valid: false,
-	entered: false,
-	message: "Only letters, numbers and underscores are allowed."
-});
-const email = ref({
-	value: "",
-	valid: false,
-	entered: false,
-	message: "Please enter a valid email address."
-});
-const password = ref({
-	value: "",
-	valid: false,
-	entered: false,
-	visible: false,
-	message:
-		"Include at least one lowercase letter, one uppercase letter, one number and one special character."
-});
+const passwordVisible = ref(false);
 const passwordElement = ref();
 
-const { register } = useUserAuthStore();
-
 const configStore = useConfigStore();
 const { registrationDisabled } = storeToRefs(configStore);
 const { openModal, closeCurrentModal } = useModalsStore();
 
-const submitModal = () => {
-	if (!username.value.valid || !email.value.valid || !password.value.valid)
-		return new Toast("Please ensure all fields are valid.");
+const { runJob } = useWebsocketStore();
+const { onReady } = useEvents();
 
-	return register({
-		username: username.value.value,
-		email: email.value.value,
-		password: password.value.value,
-	})
-		.then((res: any) => {
-			if (res.status === "success") window.location.reload();
-		})
-		.catch(err => new Toast(err.message));
-};
+const { login } = useUserAuthStore();
 
 const togglePasswordVisibility = () => {
 	if (passwordElement.value.type === "password") {
 		passwordElement.value.type = "text";
-		password.value.visible = true;
+		passwordVisible.value = true;
 	} else {
 		passwordElement.value.type = "password";
-		password.value.visible = false;
+		passwordVisible.value = false;
 	}
 };
 
@@ -73,72 +42,77 @@ const changeToLoginModal = () => {
 	openModal("login");
 };
 
-watch(
-	() => username.value.value,
-	value => {
-		username.value.entered = true;
-		if (!validation.isLength(value, 2, 32)) {
-			username.value.message =
-				"Username must have between 2 and 32 characters.";
-			username.value.valid = false;
-		} else if (!validation.regex.azAZ09_.test(value)) {
-			username.value.message =
-				"Invalid format. Allowed characters: a-z, A-Z, 0-9 and _.";
-			username.value.valid = false;
-		} else if (value.replaceAll(/[_]/g, "").length === 0) {
-			username.value.message =
-				"Invalid format. Allowed characters: a-z, A-Z, 0-9 and _, and there has to be at least one letter or number.";
-			username.value.valid = false;
-		} else {
-			username.value.message = "Everything looks great!";
-			username.value.valid = true;
+const { inputs, validate, save } = useForm(
+	{
+		username: {
+			value: null,
+			validate: (value: string) => {
+				if (!validation.isLength(value, 2, 32))
+					return "Username must have between 2 and 32 characters.";
+				if (!validation.regex.azAZ09_.test(value))
+					return "Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.";
+				if (value.replaceAll(/[_]/g, "").length === 0)
+					return "Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _, and there has to be at least one letter or number.";
+				return true;
+			}
+		},
+		emailAddress: {
+			value: null,
+			validate: (value: string) => {
+				if (!validation.isLength(value, 3, 254))
+					return "Email address must have between 3 and 254 characters.";
+				if (
+					value.indexOf("@") !== value.lastIndexOf("@") ||
+					!validation.regex.emailSimple.test(value)
+				)
+					return "Invalid email address format.";
+				return true;
+			}
+		},
+		password: {
+			value: null,
+			validate: (value: string) => {
+				if (!validation.isLength(value, 6, 200))
+					return "Password must have between 6 and 200 characters.";
+				if (!validation.regex.password.test(value))
+					return "Include at least one lowercase letter, one uppercase letter, one number and one special character.";
+				return true;
+			}
 		}
-	}
-);
-watch(
-	() => email.value.value,
-	value => {
-		email.value.entered = true;
-		if (!validation.isLength(value, 3, 254)) {
-			email.value.message =
-				"Email must have between 3 and 254 characters.";
-			email.value.valid = false;
-		} else if (
-			value.indexOf("@") !== value.lastIndexOf("@") ||
-			!validation.regex.emailSimple.test(value)
-		) {
-			email.value.message = "Invalid format.";
-			email.value.valid = false;
-		} else {
-			email.value.message = "Everything looks great!";
-			email.value.valid = true;
-		}
-	}
-);
-watch(
-	() => password.value.value,
-	value => {
-		password.value.entered = true;
-		if (!validation.isLength(value, 6, 200)) {
-			password.value.message =
-				"Password must have between 6 and 200 characters.";
-			password.value.valid = false;
-		} else if (!validation.regex.password.test(value)) {
-			password.value.message =
-				"Include at least one lowercase letter, one uppercase letter, one number and one special character.";
-			password.value.valid = false;
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success") {
+			runJob('data.users.register', {
+				query: values
+			})
+				.then(async data => {
+					await login(data.sessionId);
+
+					window.location.reload();
+
+					resolve();
+				})
+				.catch(reject);
 		} else {
-			password.value.message = "Everything looks great!";
-			password.value.valid = true;
+			if (status === "unchanged") new Toast(messages.unchanged);
+			else if (status === "error")
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
+			resolve();
 		}
 	}
 );
 
 onMounted(async () => {
-	if (registrationDisabled.value) {
-		new Toast("Registration is disabled.");
-		closeCurrentModal();
-	}
+	await onReady(async () => {
+		if (registrationDisabled.value) {
+			new Toast("Registration is disabled.");
+			closeCurrentModal();
+
+			return;
+		}
+	});
 });
 </script>
 
@@ -156,20 +130,23 @@ onMounted(async () => {
 					<p class="control">
 						<label class="label">Email</label>
 						<input
-							v-model="email.value"
+							v-model="inputs.emailAddress.value"
 							class="input"
 							type="email"
-							autocomplete="username"
+							autocomplete="email"
 							placeholder="Email..."
-							@keyup.enter="submitModal()"
+							@input="validate('emailAddress')"
+							@keyup.enter="save()"
 							autofocus
 						/>
 					</p>
 					<transition name="fadein-helpbox">
 						<input-help-box
-							:entered="email.entered"
-							:valid="email.valid"
-							:message="email.message"
+							:entered="inputs.emailAddress.value?.length > 1"
+							:valid="inputs.emailAddress.errors.length === 0"
+							:message="
+								inputs.emailAddress.errors[0] ?? 'Everything looks great!'
+							"
 						/>
 					</transition>
 
@@ -177,19 +154,22 @@ onMounted(async () => {
 					<p class="control">
 						<label class="label">Username</label>
 						<input
-							v-model="username.value"
+							v-model="inputs.username.value"
 							class="input"
 							type="text"
 							autocomplete="username"
 							placeholder="Username..."
-							@keyup.enter="submitModal()"
+							@input="validate('username')"
+							@keyup.enter="save()"
 						/>
 					</p>
 					<transition name="fadein-helpbox">
 						<input-help-box
-							:entered="username.entered"
-							:valid="username.valid"
-							:message="username.message"
+							:entered="inputs.username.value?.length > 1"
+							:valid="inputs.username.errors.length === 0"
+							:message="
+								inputs.username.errors[0] ?? 'Everything looks great!'
+							"
 						/>
 					</transition>
 
@@ -200,18 +180,19 @@ onMounted(async () => {
 
 					<div id="password-visibility-container">
 						<input
-							v-model="password.value"
+							v-model="inputs.password.value"
 							class="input"
 							type="password"
 							autocomplete="new-password"
 							ref="passwordElement"
 							placeholder="Password..."
-							@keyup.enter="submitModal()"
+							@input="validate('password')"
+							@keyup.enter="save()"
 						/>
 						<a @click="togglePasswordVisibility()">
 							<i class="material-icons">
 								{{
-									!password.visible
+									!passwordVisible
 										? "visibility"
 										: "visibility_off"
 								}}
@@ -221,9 +202,11 @@ onMounted(async () => {
 
 					<transition name="fadein-helpbox">
 						<input-help-box
-							:valid="password.valid"
-							:entered="password.entered"
-							:message="password.message"
+							:entered="inputs.password.value?.length > 1"
+							:valid="inputs.password.errors.length === 0"
+							:message="
+								inputs.password.errors[0] ?? 'Everything looks great!'
+							"
 						/>
 					</transition>
 
@@ -243,7 +226,7 @@ onMounted(async () => {
 			</template>
 			<template #footer>
 				<div id="actions">
-					<button class="button is-primary" @click="submitModal()">
+					<button class="button is-primary" @click="save()">
 						Register
 					</button>
 				</div>