Email verification
This commit is contained in:
parent
1ca3b6e20c
commit
66b82012c3
18 changed files with 671 additions and 17 deletions
2
drizzle/0002_sweet_mentallo.sql
Normal file
2
drizzle/0002_sweet_mentallo.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE `users_table` ADD `verification_code` varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE `users_table` ADD `verifcationCodeExpires` date;
|
380
drizzle/meta/0002_snapshot.json
Normal file
380
drizzle/meta/0002_snapshot.json
Normal file
|
@ -0,0 +1,380 @@
|
|||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "43f0adbb-ed0a-4d90-bb8a-1629fa070717",
|
||||
"prevId": "20bd9f86-c801-4ead-841c-558127978194",
|
||||
"tables": {
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_userId_users_table_id_fk": {
|
||||
"name": "sessions_userId_users_table_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users_table",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"sessions_token": {
|
||||
"name": "sessions_token",
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"survey_access_table": {
|
||||
"name": "survey_access_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"surveyId": {
|
||||
"name": "surveyId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recepientEmail": {
|
||||
"name": "recepientEmail",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessToken": {
|
||||
"name": "accessToken",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"token_index": {
|
||||
"name": "token_index",
|
||||
"columns": [
|
||||
"accessToken"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"survey_access_table_surveyId_surveys_table_id_fk": {
|
||||
"name": "survey_access_table_surveyId_surveys_table_id_fk",
|
||||
"tableFrom": "survey_access_table",
|
||||
"tableTo": "surveys_table",
|
||||
"columnsFrom": [
|
||||
"surveyId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"survey_access_table_id": {
|
||||
"name": "survey_access_table_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"survey_answers_table": {
|
||||
"name": "survey_answers_table",
|
||||
"columns": {
|
||||
"participantId": {
|
||||
"name": "participantId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"skillId": {
|
||||
"name": "skillId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"survey_answers_table_participantId_survey_access_table_id_fk": {
|
||||
"name": "survey_answers_table_participantId_survey_access_table_id_fk",
|
||||
"tableFrom": "survey_answers_table",
|
||||
"tableTo": "survey_access_table",
|
||||
"columnsFrom": [
|
||||
"participantId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"survey_answers_table_skillId_survey_skills_table_id_fk": {
|
||||
"name": "survey_answers_table_skillId_survey_skills_table_id_fk",
|
||||
"tableFrom": "survey_answers_table",
|
||||
"tableTo": "survey_skills_table",
|
||||
"columnsFrom": [
|
||||
"skillId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"survey_answers_table_participantId_skillId_pk": {
|
||||
"name": "survey_answers_table_participantId_skillId_pk",
|
||||
"columns": [
|
||||
"participantId",
|
||||
"skillId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"survey_skills_table": {
|
||||
"name": "survey_skills_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"surveyId": {
|
||||
"name": "surveyId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"survey_skills_table_surveyId_surveys_table_id_fk": {
|
||||
"name": "survey_skills_table_surveyId_surveys_table_id_fk",
|
||||
"tableFrom": "survey_skills_table",
|
||||
"tableTo": "surveys_table",
|
||||
"columnsFrom": [
|
||||
"surveyId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"survey_skills_table_id": {
|
||||
"name": "survey_skills_table_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"surveys_table": {
|
||||
"name": "surveys_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"owner": {
|
||||
"name": "owner",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"surveys_table_owner_users_table_id_fk": {
|
||||
"name": "surveys_table_owner_users_table_id_fk",
|
||||
"tableFrom": "surveys_table",
|
||||
"tableTo": "users_table",
|
||||
"columnsFrom": [
|
||||
"owner"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"surveys_table_id": {
|
||||
"name": "surveys_table_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"users_table": {
|
||||
"name": "users_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verification_code": {
|
||||
"name": "verification_code",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verifcationCodeExpires": {
|
||||
"name": "verifcationCodeExpires",
|
||||
"type": "date",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"users_table_id": {
|
||||
"name": "users_table_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"users_table_email_unique": {
|
||||
"name": "users_table_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
|
@ -8,6 +8,20 @@
|
|||
"when": 1732727438120,
|
||||
"tag": "0000_harsh_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1734971924726,
|
||||
"tag": "0001_lucky_proteus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1734972757800,
|
||||
"tag": "0002_sweet_mentallo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
23
package-lock.json
generated
23
package-lock.json
generated
|
@ -22,7 +22,8 @@
|
|||
"drizzle-orm": "^0.36.4",
|
||||
"layerchart": "^0.59.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mysql2": "^3.11.4"
|
||||
"mysql2": "^3.11.4",
|
||||
"nodemailer": "^6.9.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
|
@ -35,6 +36,7 @@
|
|||
"@types/debug": "^4.1.12",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.28.1",
|
||||
"eslint": "^9.7.0",
|
||||
|
@ -2444,6 +2446,16 @@
|
|||
"undici-types": "~6.19.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
|
||||
"integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
|
@ -5568,6 +5580,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.9.16",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz",
|
||||
"integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
"@types/debug": "^4.1.12",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.28.1",
|
||||
"eslint": "^9.7.0",
|
||||
|
@ -53,6 +54,7 @@
|
|||
"drizzle-orm": "^0.36.4",
|
||||
"layerchart": "^0.59.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mysql2": "^3.11.4"
|
||||
"mysql2": "^3.11.4",
|
||||
"nodemailer": "^6.9.16"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { index, int, primaryKey, mysqlTable, text, varchar, bigint } from "drizzle-orm/mysql-core";
|
||||
import { index, int, primaryKey, mysqlTable, text, varchar, bigint, date } from "drizzle-orm/mysql-core";
|
||||
|
||||
export const usersTable = mysqlTable("users_table", {
|
||||
id: int().autoincrement().primaryKey(),
|
||||
email: text().notNull().unique(),
|
||||
password_hash: text().notNull(),
|
||||
verification_code: varchar({ length: 255 }),
|
||||
verifcationCodeExpires: date(),
|
||||
});
|
||||
|
||||
export const sessions = mysqlTable("sessions", {
|
||||
|
|
61
src/db/users.ts
Normal file
61
src/db/users.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { config } from "$lib/configuration";
|
||||
import { generateRandomToken } from "$lib/randomToken";
|
||||
import type { Email, Password, VerificationCode } from "$lib/types";
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import { db } from ".";
|
||||
import { usersTable } from "./schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { err, ok, type Result } from "$lib/result";
|
||||
import debug from "debug";
|
||||
|
||||
const log = debug('db:users');
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
email: Email;
|
||||
}
|
||||
|
||||
export async function createNewUser(email: Email, password: Password): Promise<{ verificationCode: VerificationCode | undefined }> {
|
||||
const hashedPassword = await hash(password);
|
||||
|
||||
if (config.emailVerificationDisabled) {
|
||||
log("WARNING: Email verification is disabled. Accounts will be enabled immediately. You should not run this in production.");
|
||||
}
|
||||
|
||||
const verificationCode = config.emailVerificationDisabled ? undefined : generateRandomToken() as VerificationCode;
|
||||
|
||||
await db.insert(usersTable).values({
|
||||
email,
|
||||
password_hash: hashedPassword,
|
||||
verification_code: verificationCode,
|
||||
verifcationCodeExpires: verificationCode ? new Date(Date.now() + 1000 * 60 * 60 * 24 * 3) : undefined
|
||||
});
|
||||
|
||||
return {
|
||||
verificationCode
|
||||
}
|
||||
}
|
||||
|
||||
export enum VerificationError {
|
||||
InvalidVerificationCode = 'Invalid verification code',
|
||||
VerificationCodeExpired = 'Verification code expired'
|
||||
}
|
||||
|
||||
export async function verifyUser(verificationCode: VerificationCode): Promise<Result<User, VerificationError>> {
|
||||
let user = await db.select().from(usersTable).where(eq(usersTable.verification_code, verificationCode)).limit(1);
|
||||
|
||||
if (user.length === 0) {
|
||||
return err(VerificationError.InvalidVerificationCode);
|
||||
}
|
||||
|
||||
if (user[0].verifcationCodeExpires && user[0].verifcationCodeExpires < new Date()) {
|
||||
return err(VerificationError.VerificationCodeExpired);
|
||||
}
|
||||
|
||||
await db.update(usersTable).set({ verification_code: null, verifcationCodeExpires: null }).where(eq(usersTable.verification_code, verificationCode));
|
||||
|
||||
return ok({
|
||||
id: user[0].id,
|
||||
email: user[0].email as Email
|
||||
});
|
||||
}
|
7
src/lib/branded.ts
Normal file
7
src/lib/branded.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
declare const BRAND: unique symbol;
|
||||
|
||||
export type Brand<B> = {
|
||||
[BRAND]: B
|
||||
}
|
||||
|
||||
export type Branded<T, B> = T & Brand<B>;
|
44
src/lib/configuration.ts
Normal file
44
src/lib/configuration.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { env } from '$env/dynamic/private';
|
||||
|
||||
let allowedDomains: string[]
|
||||
|
||||
export const config = {
|
||||
|
||||
get allowableDomains() {
|
||||
if (!allowedDomains) {
|
||||
allowedDomains = env.ALLOWABLE_DOMAINS?.split(',') ?? [];
|
||||
}
|
||||
return allowedDomains;
|
||||
},
|
||||
|
||||
/// Returns true if the config disabled the verification of emails on registration
|
||||
get emailVerificationDisabled() {
|
||||
return env.EMAIL_VERIFICATION_DISABLED === 'true';
|
||||
},
|
||||
|
||||
/// Get the sender email address
|
||||
get senderFrom() {
|
||||
return env.SENDER_FROM;
|
||||
},
|
||||
|
||||
/// Get the server config
|
||||
get emailServer() {
|
||||
return emailServerConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/// The config for the email server
|
||||
const emailServerConfig = {
|
||||
get host() {
|
||||
return env.EMAIL_SERVER_HOST;
|
||||
},
|
||||
get port() {
|
||||
return env.EMAIL_SERVER_PORT ? parseInt(env.EMAIL_SERVER_PORT) : 587;
|
||||
},
|
||||
get user() {
|
||||
return env.EMAIL_SERVER_USER;
|
||||
},
|
||||
get password() {
|
||||
return env.EMAIL_SERVER_PASSWORD;
|
||||
}
|
||||
}
|
36
src/lib/emails/index.ts
Normal file
36
src/lib/emails/index.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import * as nodemailer from "nodemailer";
|
||||
|
||||
import type { Email } from "$lib/types";
|
||||
import { config } from "$lib/configuration";
|
||||
import { lazy } from "$lib/lazy";
|
||||
|
||||
const emailTransport = lazy(() => {
|
||||
|
||||
|
||||
if (!config.emailServer.host) {
|
||||
throw new Error("Tried to send email without a configured email server");
|
||||
}
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: config.emailServer.host,
|
||||
port: config.emailServer.port ?? 587,
|
||||
auth: {
|
||||
user: config.emailServer.user,
|
||||
pass: config.emailServer.password
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export function sendEmail(recepientEmail: Email, title: string, bodyText: string) {
|
||||
try {
|
||||
emailTransport().sendMail({
|
||||
from: config.senderFrom,
|
||||
to: recepientEmail,
|
||||
subject: title,
|
||||
text: bodyText
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Failed to send email", e);
|
||||
}
|
||||
}
|
15
src/lib/emails/verification.ts
Normal file
15
src/lib/emails/verification.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import type { Email, VerificationCode } from "$lib/types";
|
||||
import { sendEmail } from ".";
|
||||
|
||||
export function sendVerificationEmail(email: Email, verificationCode: VerificationCode) {
|
||||
sendEmail(email, 'Welcome to Three60 surveys', `Hello,
|
||||
|
||||
please confirm your account email address by clicking the link below:
|
||||
|
||||
http://localhost:5173/verify/${verificationCode}
|
||||
|
||||
Thank you.
|
||||
|
||||
P.S.: If you didn't request this email, please ignore it. The registration will expire in a few days and all related data will be deleted.
|
||||
`);
|
||||
}
|
12
src/lib/lazy.ts
Normal file
12
src/lib/lazy.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
|
||||
/// lazily instantiate an instance of T and store it for further use
|
||||
export function lazy<T>(fn: () => T): () => T {
|
||||
let value: T;
|
||||
|
||||
return () => {
|
||||
if (!value) {
|
||||
value = fn();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
19
src/lib/result.ts
Normal file
19
src/lib/result.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export type Ok<T> = { type: 'OK', value: T };
|
||||
export type Err<E> = { type: 'ERR', error: E };
|
||||
export type Result<T, E> = Ok<T> | Err<E>;
|
||||
|
||||
export function ok<T>(value: T): Ok<T> {
|
||||
return { type: 'OK', value };
|
||||
}
|
||||
|
||||
export function isOk<T>(result: Result<T, unknown>): result is Ok<T> {
|
||||
return result.type === 'OK';
|
||||
}
|
||||
|
||||
export function err<E>(error: E): Err<E> {
|
||||
return { type: 'ERR', error };
|
||||
}
|
||||
|
||||
export function isErr<E>(result: Result<unknown, E>): result is Err<E> {
|
||||
return result.type === 'ERR';
|
||||
}
|
5
src/lib/types.ts
Normal file
5
src/lib/types.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import type { Branded } from "./branded";
|
||||
|
||||
export type Email = Branded<string, 'email'>;
|
||||
export type Password = Branded<string, 'password'>;
|
||||
export type VerificationCode = Branded<string, 'verificationCode'>;
|
|
@ -26,6 +26,10 @@ export const actions = {
|
|||
if (userCredentials.length === 0) {
|
||||
error(403, 'Invalid credentials');
|
||||
}
|
||||
if (userCredentials[0].verification_code) {
|
||||
log('Email %s not yet verified', email);
|
||||
error(403, "Your account is not yet verified");
|
||||
}
|
||||
const isPasswordValid = await verify(userCredentials[0].password_hash, password);
|
||||
if (isPasswordValid) {
|
||||
// create the session
|
||||
|
|
|
@ -1,37 +1,35 @@
|
|||
import { hash } from '@node-rs/argon2';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { usersTable } from '../../db/schema';
|
||||
import { db } from '../../db';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { sendVerificationEmail } from '$lib/emails/verification';
|
||||
import { createNewUser } from '../../db/users';
|
||||
import type { Email, Password } from '$lib/types';
|
||||
import { config } from '$lib/configuration';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return {
|
||||
allowableDomains: env.ALLOWABLE_DOMAINS
|
||||
allowableDomains: config.allowableDomains
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: async (event) => {
|
||||
const allowedDomains = env.ALLOWABLE_DOMAINS?.split(',');
|
||||
const formData = await event.request.formData();
|
||||
const email = formData.get('email')?.toString();
|
||||
const password = formData.get('password')?.toString();
|
||||
const email = formData.get('email')?.toString() as Email | undefined;
|
||||
const password = formData.get('password')?.toString() as Password | undefined;
|
||||
|
||||
if (!email || !password) {
|
||||
error(400, 'Email and password are required');
|
||||
}
|
||||
|
||||
if (allowedDomains && !allowedDomains?.some(domain => email.endsWith(domain))) {
|
||||
if (config.allowableDomains && !config.allowableDomains?.some(domain => email.endsWith(domain))) {
|
||||
error(400, 'Invalid email domain');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(password);
|
||||
const result = await createNewUser(email, password);
|
||||
|
||||
await db.insert(usersTable).values({
|
||||
email,
|
||||
password_hash: hashedPassword
|
||||
});
|
||||
if (result.verificationCode) {
|
||||
sendVerificationEmail(email, result.verificationCode);
|
||||
}
|
||||
|
||||
redirect(303, '/login');
|
||||
}
|
||||
|
|
23
src/routes/verify/[verificationCode]/+page.server.ts
Normal file
23
src/routes/verify/[verificationCode]/+page.server.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { isErr, isOk } from "$lib/result";
|
||||
import type { VerificationCode } from "$lib/types";
|
||||
import debug from "debug";
|
||||
import { verifyUser } from "../../../db/users";
|
||||
import type { PageServerLoad } from "./$types"
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
let log = debug('verify');
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const verificationCode = params.verificationCode;
|
||||
|
||||
const userResult = await verifyUser(verificationCode as VerificationCode);
|
||||
|
||||
if (isErr(userResult)) {
|
||||
log('Failed to verify user with code %s: %s', verificationCode, userResult.error);
|
||||
error(403, `Cannot verify user: ${userResult.error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
email: userResult.value.email
|
||||
};
|
||||
}
|
9
src/routes/verify/[verificationCode]/+page.svelte
Normal file
9
src/routes/verify/[verificationCode]/+page.svelte
Normal file
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
const { data } = $props();
|
||||
</script>
|
||||
|
||||
<h1 class="text-3xl">Verification successful</h1>
|
||||
<div>
|
||||
Congratulations! You've successfully verified your account. <a href="/login">Click here</a> to log
|
||||
in.
|
||||
</div>
|
Loading…
Add table
Reference in a new issue