Browse Source

Create backend for user registration

Lee Morgan 4 days ago
parent
commit
13375aa6d0
5 changed files with 236 additions and 7 deletions
  1. 63 0
      package-lock.json
  2. 2 0
      package.json
  3. 111 0
      src/lib/server/users.js
  4. 22 0
      src/routes/register/+page.server.js
  5. 38 7
      src/routes/register/+page.svelte

+ 63 - 0
package-lock.json

@@ -8,12 +8,14 @@
 			"name": "torus",
 			"version": "0.0.1",
 			"dependencies": {
+				"bcrypt": "^6.0.0",
 				"mongodb": "^7.6.0"
 			},
 			"devDependencies": {
 				"@sveltejs/adapter-auto": "^7.0.1",
 				"@sveltejs/kit": "^2.63.0",
 				"@sveltejs/vite-plugin-svelte": "^7.1.2",
+				"@types/bcrypt": "^6.0.0",
 				"svelte": "^5.56.1",
 				"vite": "^8.0.16"
 			}
@@ -473,6 +475,16 @@
 				"@jridgewell/sourcemap-codec": "^1.6.0"
 			}
 		},
+		"node_modules/@types/bcrypt": {
+			"version": "6.0.0",
+			"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
+			"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/node": "*"
+			}
+		},
 		"node_modules/@types/cookie": {
 			"version": "0.6.0",
 			"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -487,6 +499,16 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/@types/node": {
+			"version": "22.20.2",
+			"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
+			"integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"undici-types": "~6.21.0"
+			}
+		},
 		"node_modules/@types/webidl-conversions": {
 			"version": "7.0.3",
 			"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@@ -535,6 +557,20 @@
 				"node": ">= 0.4"
 			}
 		},
+		"node_modules/bcrypt": {
+			"version": "6.0.0",
+			"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+			"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
+			"hasInstallScript": true,
+			"license": "MIT",
+			"dependencies": {
+				"node-addon-api": "^8.3.0",
+				"node-gyp-build": "^4.8.4"
+			},
+			"engines": {
+				"node": ">= 18"
+			}
+		},
 		"node_modules/bson": {
 			"version": "7.3.2",
 			"resolved": "https://registry.npmjs.org/bson/-/bson-7.3.2.tgz",
@@ -1053,6 +1089,26 @@
 				"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
 			}
 		},
+		"node_modules/node-addon-api": {
+			"version": "8.9.2",
+			"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
+			"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
+			"license": "MIT",
+			"engines": {
+				"node": "^18 || ^20 || >= 21"
+			}
+		},
+		"node_modules/node-gyp-build": {
+			"version": "4.8.4",
+			"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+			"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+			"license": "MIT",
+			"bin": {
+				"node-gyp-build": "bin.js",
+				"node-gyp-build-optional": "optional.js",
+				"node-gyp-build-test": "build-test.js"
+			}
+		},
 		"node_modules/obug": {
 			"version": "2.2.1",
 			"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
@@ -1266,6 +1322,13 @@
 				"node": ">=18"
 			}
 		},
+		"node_modules/undici-types": {
+			"version": "6.21.0",
+			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+			"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/vite": {
 			"version": "8.3.0",
 			"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",

+ 2 - 0
package.json

@@ -13,10 +13,12 @@
 		"@sveltejs/adapter-auto": "^7.0.1",
 		"@sveltejs/kit": "^2.63.0",
 		"@sveltejs/vite-plugin-svelte": "^7.1.2",
+		"@types/bcrypt": "^6.0.0",
 		"svelte": "^5.56.1",
 		"vite": "^8.0.16"
 	},
 	"dependencies": {
+		"bcrypt": "^6.0.0",
 		"mongodb": "^7.6.0"
 	}
 }

+ 111 - 0
src/lib/server/users.js

@@ -0,0 +1,111 @@
+import { randomUUID } from 'node:crypto';
+import bcrypt from 'bcrypt';
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const BCRYPT_ROUNDS = 12;
+const USERS = 'users';
+
+/**
+ * @param {string} email
+ * @returns {string}
+ */
+export function normalizeEmail(email) {
+	return String(email ?? '')
+		.trim()
+		.toLowerCase();
+}
+
+/**
+ * @param {string} email
+ * @returns {boolean}
+ */
+export function isValidEmail(email) {
+	return EMAIL_RE.test(email);
+}
+
+/**
+ * @param {{ name?: string, email?: string, password?: string, confirmPassword?: string }} input
+ * @returns {{ ok: true, name: string, email: string, password: string } | { ok: false, message: string }}
+ */
+export function validateRegistration(input) {
+	const name = String(input.name ?? '').trim();
+	const email = normalizeEmail(input.email);
+	const password = String(input.password ?? '');
+	const confirmPassword = String(input.confirmPassword ?? '');
+
+	if (!name) {
+		return { ok: false, message: 'Name is required' };
+	}
+
+	if (!email || !isValidEmail(email)) {
+		return { ok: false, message: 'Enter a valid email' };
+	}
+
+	if (password !== confirmPassword) {
+		return { ok: false, message: 'Passwords do not match' };
+	}
+
+	if (password.length <= 12) {
+		return { ok: false, message: 'Password must be longer than 12 characters' };
+	}
+
+	return { ok: true, name, email, password };
+}
+
+/**
+ * Create a user document and insert it into MongoDB.
+ * @param {import('mongodb').Db} db
+ * @param {{ name: string, email: string, password: string }} data
+ * @returns {Promise<{ ok: true, user: { uuid: string, name: string, email: string } } | { ok: false, message: string }>}
+ */
+export async function createUser(db, data) {
+	const users = db.collection(USERS);
+
+	await users.createIndex({ email: 1 }, { unique: true });
+	await users.createIndex({ uuid: 1 }, { unique: true });
+
+	const existing = await users.findOne({ email: data.email }, { projection: { _id: 1 } });
+	if (existing) {
+		return { ok: false, message: 'Email already registered' };
+	}
+
+	const uuid = randomUUID();
+	const passwordHash = await bcrypt.hash(data.password, BCRYPT_ROUNDS);
+
+	const user = {
+		uuid,
+		name: data.name,
+		email: data.email,
+		password: passwordHash
+	};
+
+	try {
+		await users.insertOne(user);
+	} catch (error) {
+		if (error && typeof error === 'object' && 'code' in error && error.code === 11000) {
+			return { ok: false, message: 'Email already registered' };
+		}
+		throw error;
+	}
+
+	return {
+		ok: true,
+		user: { uuid: user.uuid, name: user.name, email: user.email }
+	};
+}
+
+/**
+ * Validate input and create a user.
+ * @param {import('mongodb').Db} db
+ * @param {{ name?: string, email?: string, password?: string, confirmPassword?: string }} input
+ */
+export async function registerUser(db, input) {
+	const validated = validateRegistration(input);
+	if (!validated.ok) return validated;
+
+	return createUser(db, {
+		name: validated.name,
+		email: validated.email,
+		password: validated.password
+	});
+}

+ 22 - 0
src/routes/register/+page.server.js

@@ -0,0 +1,22 @@
+import { fail, redirect } from '@sveltejs/kit';
+import { registerUser } from '$lib/server/users.js';
+
+/** @type {import('./$types').Actions} */
+export const actions = {
+	default: async ({ request, locals }) => {
+		const form = await request.formData();
+
+		const result = await registerUser(locals.db, {
+			name: String(form.get('name') ?? ''),
+			email: String(form.get('email') ?? ''),
+			password: String(form.get('password') ?? ''),
+			confirmPassword: String(form.get('confirmPassword') ?? '')
+		});
+
+		if (!result.ok) {
+			return fail(400, { message: result.message });
+		}
+
+		redirect(303, '/login');
+	}
+};

+ 38 - 7
src/routes/register/+page.svelte

@@ -1,4 +1,5 @@
 <script>
+	import { applyAction, enhance } from '$app/forms';
 	import logoMark from '$lib/images/logo_white.svg';
 	import { notifier } from '$lib/notifier.svelte.js';
 
@@ -6,11 +7,7 @@
 	let email = $state('');
 	let password = $state('');
 	let confirmPassword = $state('');
-
-	function handleSubmit(event) {
-		event.preventDefault();
-		notifier.info('Registration submitted (test)');
-	}
+	let submitting = $state(false);
 </script>
 
 <svelte:head>
@@ -34,7 +31,33 @@
 				<p class="lede">Name, email, password. Then get to work.</p>
 			</div>
 
-			<form class="form" method="POST" onsubmit={handleSubmit}>
+			<form
+				class="form"
+				method="POST"
+				use:enhance={() => {
+					submitting = true;
+					return async ({ result, update }) => {
+						submitting = false;
+
+						if (result.type === 'failure') {
+							const message =
+								result.data && typeof result.data === 'object' && 'message' in result.data
+									? String(result.data.message)
+									: 'Registration failed';
+							notifier.fail(message);
+							await update({ reset: false });
+						} else if (result.type === 'redirect') {
+							notifier.ok('Account created');
+							await applyAction(result);
+						} else if (result.type === 'error') {
+							notifier.fail(result.error?.message ?? 'Registration failed');
+							await update({ reset: false });
+						} else {
+							await update({ reset: false });
+						}
+					};
+				}}
+			>
 				<label class="field">
 					<span class="label">Name</span>
 					<input
@@ -44,6 +67,7 @@
 						autocomplete="name"
 						required
 						autofocus
+						disabled={submitting}
 						bind:value={name}
 					/>
 				</label>
@@ -56,6 +80,7 @@
 						name="email"
 						autocomplete="email"
 						required
+						disabled={submitting}
 						bind:value={email}
 					/>
 				</label>
@@ -68,6 +93,8 @@
 						name="password"
 						autocomplete="new-password"
 						required
+						minlength="13"
+						disabled={submitting}
 						bind:value={password}
 					/>
 				</label>
@@ -80,11 +107,15 @@
 						name="confirmPassword"
 						autocomplete="new-password"
 						required
+						minlength="13"
+						disabled={submitting}
 						bind:value={confirmPassword}
 					/>
 				</label>
 
-				<button class="btn btn-primary btn-submit" type="submit">Create account</button>
+				<button class="btn btn-primary btn-submit" type="submit" disabled={submitting}>
+					{submitting ? 'Creating…' : 'Create account'}
+				</button>
 			</form>
 
 			<p class="switch">