4 Commits 61142ef2ce ... 1777b4343d

Author SHA1 Message Date
  Owen Diffey 1777b4343d refactor: Update user validation and constraints 1 week ago
  Owen Diffey 004fb1e127 feat: Installs citext extension in migration 1 week ago
  Owen Diffey 4b5d2b65a5 feat: Starts updating registration functionality 1 week ago
  Owen Diffey cc8ff9c2c7 refactor: Update team page profile picture usage 1 week ago

+ 18 - 0
backend/src/modules/DataModule/migrations/1725485640-create-citext-extension.ts

@@ -0,0 +1,18 @@
+import { Sequelize, DataTypes } from "sequelize";
+import { MigrationParams } from "umzug";
+
+export const up = async ({
+	context: sequelize
+}: MigrationParams<Sequelize>) => {
+	await sequelize.query(
+		"CREATE EXTENSION citext"
+	);
+};
+
+export const down = async ({
+	context: sequelize
+}: MigrationParams<Sequelize>) => {
+	await sequelize.query(
+		"DROP EXTENSION citext"
+	);
+};

+ 12 - 15
backend/src/modules/DataModule/migrations/1725485641-create-users-table.ts

@@ -10,11 +10,13 @@ export const up = async ({
 			// @ts-ignore
 			type: DataTypes.OBJECTID,
 			autoNull: false,
-			primaryKey: true
+			primaryKey: true,
+			unique: true
 		},
 		username: {
-			type: DataTypes.STRING,
-			allowNull: false
+			type: DataTypes.CITEXT,
+			allowNull: false,
+			unique: true
 		},
 		role: {
 			type: DataTypes.ENUM("admin", "moderator", "user"),
@@ -27,11 +29,13 @@ export const up = async ({
 		},
 		emailVerificationToken: {
 			type: DataTypes.STRING,
-			allowNull: true
+			allowNull: true,
+			unique: true
 		},
 		emailAddress: {
-			type: DataTypes.STRING,
-			allowNull: false
+			type: DataTypes.CITEXT,
+			allowNull: false,
+			unique: true
 		},
 		avatarType: {
 			type: DataTypes.ENUM("gravatar", "initials"),
@@ -58,20 +62,13 @@ export const up = async ({
 		},
 		passwordResetCode: {
 			type: DataTypes.STRING,
-			allowNull: true
+			allowNull: true,
+			unique: true
 		},
 		passwordResetExpiresAt: {
 			type: DataTypes.DATE,
 			allowNull: true
 		},
-		passwordSetCode: {
-			type: DataTypes.STRING,
-			allowNull: true
-		},
-		passwordSetExpiresAt: {
-			type: DataTypes.DATE,
-			allowNull: true
-		},
 		songsRequested: {
 			type: DataTypes.BIGINT,
 			allowNull: false,

+ 12 - 20
backend/src/modules/DataModule/models/User.ts

@@ -55,10 +55,6 @@ export class User extends Model<
 
 	declare passwordResetExpiresAt: CreationOptional<Date | null>;
 
-	declare passwordSetCode: CreationOptional<string | null>;
-
-	declare passwordSetExpiresAt: CreationOptional<Date | null>;
-
 	// End services
 
 	// Statistics
@@ -164,11 +160,13 @@ export const schema = {
 	_id: {
 		type: DataTypes.OBJECTID,
 		autoNull: false,
-		primaryKey: true
+		primaryKey: true,
+		unique: true
 	},
 	username: {
-		type: DataTypes.STRING,
-		allowNull: false
+		type: DataTypes.CITEXT,
+		allowNull: false,
+		unique: true
 	},
 	role: {
 		type: DataTypes.ENUM(...Object.values(UserRole)),
@@ -181,11 +179,13 @@ export const schema = {
 	},
 	emailVerificationToken: {
 		type: DataTypes.STRING,
-		allowNull: true
+		allowNull: true,
+		unique: true
 	},
 	emailAddress: {
-		type: DataTypes.STRING,
-		allowNull: false
+		type: DataTypes.CITEXT,
+		allowNull: false,
+		unique: true
 	},
 	avatarType: {
 		type: DataTypes.ENUM(...Object.values(UserAvatarType)),
@@ -205,20 +205,13 @@ export const schema = {
 	},
 	passwordResetCode: {
 		type: DataTypes.STRING,
-		allowNull: true
+		allowNull: true,
+		unique: true
 	},
 	passwordResetExpiresAt: {
 		type: DataTypes.DATE,
 		allowNull: true
 	},
-	passwordSetCode: {
-		type: DataTypes.STRING,
-		allowNull: true
-	},
-	passwordSetExpiresAt: {
-		type: DataTypes.DATE,
-		allowNull: true
-	},
 	songsRequested: {
 		type: DataTypes.BIGINT,
 		allowNull: false,
@@ -289,7 +282,6 @@ export const options = {
 				"emailVerificationToken",
 				"password",
 				"passwordResetCode",
-				"passwordSetCode",
 			]
 		}
 	}

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

+ 34 - 3
backend/src/modules/DataModule/models/User/jobs/UpdateById.ts

@@ -5,6 +5,7 @@ import { UserAvatarType } from "../UserAvatarType";
 import { UserAvatarColor } from "../UserAvatarColor";
 import isAdmin from "@/modules/DataModule/permissions/isAdmin";
 import isSelf from "@/modules/DataModule/permissions/modelPermissions/isSelf";
+import { Op } from "sequelize";
 
 export default class UpdateById extends UpdateByIdJob {
 	protected static _model = User;
@@ -18,8 +19,8 @@ export default class UpdateById extends UpdateByIdJob {
 			.pattern(/^[0-9a-fA-F]{24}$/)
 			.required(),
 		query: Joi.object({
-			username: Joi.string().max(64).optional(), // TODO: format and unique validation
-			emailAddress: Joi.string().max(64).optional(), // TODO: format and unique validation
+			username: Joi.string().min(2).max(32).regex(/^_*[a-zA-Z0-9][a-zA-Z0-9_]*$/).optional(),
+			emailAddress: Joi.string().email().min(3).max(254).optional(), // TODO: Whitelist regex
 			name: Joi.string().max(64).optional(),
 			location: Joi.string().max(50).optional().allow(null, ""),
 			bio: Joi.string().max(200).optional().allow(null, ""), // TODO: Nullify empty strings
@@ -33,5 +34,35 @@ export default class UpdateById extends UpdateByIdJob {
 			anonymousSongRequests: Joi.boolean().optional(),
 			activityWatch: Joi.boolean().optional()
 		}).required()
-	});
+	})
+		.external(async ({ _id, query }) => {
+			if (query.emailAddress === undefined) return;
+
+			const user = await User.findOne({
+				where: {
+					[Op.not]: {
+						_id
+					},
+					username: query.username
+				}
+			});
+
+			if (user instanceof User)
+				throw new Error("A user with that username already exists.");
+		}, "uniqueUsername")
+		.external(async ({ _id, query }) => {
+			if (query.emailAddress === undefined) return;
+
+			const user = await User.findOne({
+				where: {
+					[Op.not]: {
+						_id
+					},
+					emailAddress: query.emailAddress
+				}
+			});
+
+			if (user instanceof User)
+				throw new Error("A user with that email already exists.");
+		}, "uniqueEmail");
 }

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

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

@@ -28,10 +28,7 @@ const currentTeam = ref([
 		active: "Sept 2015 - present",
 		github: "KrisVos130",
 		link: "https://kvos.dev",
-		avatar: {
-			type: "text",
-			color: "orange"
-		}
+		avatarColor: "orange"
 	},
 	{
 		name: "Owen Diffey",
@@ -46,10 +43,7 @@ const currentTeam = ref([
 		active: "Feb 2016 - present",
 		github: "odiffey",
 		link: "https://diffey.dev",
-		avatar: {
-			type: "text",
-			color: "purple"
-		}
+		avatarColor: "purple"
 	}
 ]);
 const previousTeam = ref([
@@ -139,7 +133,7 @@ const otherContributors = ref([
 					>
 						<header class="card-header">
 							<profile-picture
-								:avatar="member.avatar"
+								:color="member.avatarColor"
 								:name="member.name"
 							/>
 							<div>
@@ -191,7 +185,7 @@ const otherContributors = ref([
 					>
 						<header class="card-header">
 							<profile-picture
-								:avatar="{ type: 'text', color: 'grey' }"
+								color="grey"
 								:name="member.name"
 							/>
 							<div>