feat: worked on email verification
This commit is contained in:
parent
9881f79534
commit
d1e93a3631
7 changed files with 213 additions and 31 deletions
|
@ -91,16 +91,31 @@ export class AuthController {
|
|||
|
||||
@Get('verify-email')
|
||||
@UseGuards(LoginGuard)
|
||||
@Render('auth/verify-email')
|
||||
@ApiExcludeEndpoint()
|
||||
public async getVerifyEmail(@Query('code') code?: string): Promise<any> {
|
||||
public async verifyEmail(@Res() response: Response, @Query('code') code: string): Promise<any> {
|
||||
if (!code) {
|
||||
//TODO: Write error page.
|
||||
return response.render('base/error', {
|
||||
error_header: 'Invalid Verification Code',
|
||||
error_message:
|
||||
'The verification code provided is invalid. Please try sending your verification email again.',
|
||||
button_name: 'Go Back to Login',
|
||||
button_link: '/auth/login',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
login: 'login',
|
||||
};
|
||||
try {
|
||||
await this.authService.markEmailVerified(code);
|
||||
} catch (e) {
|
||||
return response.render('base/error', {
|
||||
error_header: 'Invalid Verification Code',
|
||||
error_message:
|
||||
'The verification code provided is invalid. Please try sending your verification email again.',
|
||||
button_name: 'Go Back to Login',
|
||||
button_link: '/auth/login',
|
||||
});
|
||||
}
|
||||
|
||||
response.redirect('/auth/login');
|
||||
}
|
||||
|
||||
//TODO: Work on interaction view.
|
||||
|
|
|
@ -42,8 +42,11 @@ export class AuthService {
|
|||
});
|
||||
}
|
||||
|
||||
await this.userService.createUser(username, email, password);
|
||||
await this.mailService.sendVerificationEmail(email, '11111');
|
||||
const user = await this.userService.createUser(username, email, password);
|
||||
|
||||
const emailVerificationCode = await this.generateCode(PASSWORD_RESET_CACHE_KEY);
|
||||
await this.storeEmailVerifyCode(emailVerificationCode, user.id);
|
||||
await this.mailService.sendVerificationEmail(email, emailVerificationCode);
|
||||
return { error: false, message: 'User registered' };
|
||||
}
|
||||
|
||||
|
@ -180,7 +183,7 @@ export class AuthService {
|
|||
await this.cleanupOldEmailVerificationCode(userId);
|
||||
|
||||
await Promise.all([
|
||||
this.redisService.set(code, userId, PASSWORD_RESET_EXPIRATION),
|
||||
this.redisService.set(getEmailVerifyKey(code), userId, PASSWORD_RESET_EXPIRATION),
|
||||
this.redisService.set(getUserToVerifyKey(userId), code, PASSWORD_RESET_EXPIRATION),
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -25,8 +25,8 @@ export class User {
|
|||
@Column({ length: MAX_STRING_LENGTH, unique: true })
|
||||
email: string;
|
||||
|
||||
@Column({ name: 'pending_email', length: MAX_STRING_LENGTH, nullable: true })
|
||||
pendingEmail: string | null;
|
||||
@Column({ name: 'pending_email', type: String, length: MAX_STRING_LENGTH, nullable: true })
|
||||
pendingEmail?: string | null;
|
||||
|
||||
@Column({ name: 'email_verified', default: false })
|
||||
emailVerified: boolean;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
|
@ -13,6 +14,7 @@ import { RedisService } from '../../redis/service/redis.service';
|
|||
import { Span } from 'nestjs-otel';
|
||||
import {
|
||||
DISABLED_USER_ERROR,
|
||||
EMAIL_NOT_VERIFIED_ERROR,
|
||||
INVALID_CREDENTIALS_ERROR,
|
||||
USER_NOT_FOUND_ERROR,
|
||||
userCacheKeyGenerate,
|
||||
|
@ -36,7 +38,7 @@ export class UserService {
|
|||
* @throws NotFoundException
|
||||
*/
|
||||
@Span()
|
||||
async getUserById(id: number, relations: string[] = []): Promise<User> {
|
||||
async getUserById(id: number | string, relations: string[] = []): Promise<User> {
|
||||
if (this.clsService.get('authType') === 'session') {
|
||||
if (this.clsService.get('user').id === id) {
|
||||
return this.clsService.get('user');
|
||||
|
@ -142,9 +144,9 @@ export class UserService {
|
|||
async createUser(username: string, email: string, password: string): Promise<User> {
|
||||
const hashedPassword = await hash(password);
|
||||
|
||||
const user = this.userRepository.create({ email, username, password: hashedPassword });
|
||||
const userObject = this.userRepository.create({ email, username, password: hashedPassword });
|
||||
|
||||
return await this.userRepository.save(user);
|
||||
return await this.userRepository.save(userObject);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -171,6 +173,10 @@ export class UserService {
|
|||
throw new UnauthorizedException(INVALID_CREDENTIALS_ERROR);
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException(EMAIL_NOT_VERIFIED_ERROR);
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
throw new UnauthorizedException(DISABLED_USER_ERROR);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ export const USER_NOT_FOUND_ERROR = 'User not found';
|
|||
export const INVALID_CREDENTIALS_ERROR =
|
||||
'The email or password you entered is incorrect or the user was not found';
|
||||
export const DISABLED_USER_ERROR = 'User is disabled';
|
||||
export const EMAIL_NOT_VERIFIED_ERROR = 'Email requires verification';
|
||||
|
||||
// Caching Constants for Redis
|
||||
|
||||
|
|
|
@ -38,6 +38,17 @@
|
|||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.shake {
|
||||
animation: shake 0.5s;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
@keyframes shake {
|
||||
0% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
50% { transform: translateX(5px); }
|
||||
75% { transform: translateX(-5px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="relative flex items-center justify-start min-h-screen">
|
||||
|
@ -53,10 +64,11 @@
|
|||
<div class="relative bg-gray-800 p-8 rounded-lg shadow-lg w-full max-w-md ml-16 login-prompt">
|
||||
<h2 class="text-2xl font-bold mb-2 text-white text-center">Welcome back!</h2>
|
||||
<p class="text-gray-400 mb-6 text-center">We're so excited to see you again!</p>
|
||||
<form action="{{ login_url }}" method="POST" class="space-y-6">
|
||||
<form id="loginForm" action="{{ login_url }}" method="POST" class="space-y-6">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-400">Email or Username</label>
|
||||
<input type="text" placeholder="yip@yap.yop" name="username" required class="mt-1 block w-full px-3 py-2 bg-gray-700 text-white border border-gray-600 rounded-md shadow-sm placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<p id="username-error" class="text-xs text-red-600 hidden"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-400">Password</label>
|
||||
|
@ -74,8 +86,8 @@
|
|||
<div class="mt-4 text-sm text-center">
|
||||
<span class="text-gray-400">Need an account? </span><a href="{{ register }}" class="font-medium text-indigo-500 hover:text-indigo-400">Register</a>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center justify-center text-gray-400 text-xs">
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 784.69 187.35" width="40%" height="40%">
|
||||
<div class="mt-6 flex items-center justify-center text-gray-400 text-xs">
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 784.69 187.35" width="40%" height="40%">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
|
@ -111,8 +123,88 @@
|
|||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
const loginData = {
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password'),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(loginData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessageDiv = document.getElementById('username-error');
|
||||
const usernameInput = form.querySelector('input[name="username"]');
|
||||
const passwordInput = form.querySelector('input[name="password"]');
|
||||
errorMessageDiv.classList.remove('hidden');
|
||||
usernameInput.classList.add('border-red-600', 'shake');
|
||||
passwordInput.classList.add('border-red-600', 'shake');
|
||||
|
||||
setTimeout(() => {
|
||||
usernameInput.classList.remove('shake');
|
||||
passwordInput.classList.remove('shake');
|
||||
}, 500);
|
||||
|
||||
if (response.status === 401) {
|
||||
errorMessageDiv.textContent = 'Incorrect Password/Username';
|
||||
} else if (response.status === 403) {
|
||||
const errorData = await response.json();
|
||||
if (errorData.message === 'Account is disabled') {
|
||||
errorMessageDiv.textContent = 'Account is disabled';
|
||||
} else if (errorData.message === 'Email requires verification') {
|
||||
errorMessageDiv.innerHTML = 'Email requires verification. <button id="resend-verification" class="text-indigo-500 hover:text-indigo-400">Resend verification email</button>';
|
||||
document.getElementById('resend-verification').addEventListener('click', async function() {
|
||||
try {
|
||||
const emailResponse = await fetch('/auth/email-verification', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email: loginData.username }),
|
||||
});
|
||||
|
||||
if (emailResponse.status === 429) {
|
||||
errorMessageDiv.textContent = 'Rate limit exceeded. Please wait.';
|
||||
} else if (emailResponse.ok) {
|
||||
alert('Verification email resent!');
|
||||
} else {
|
||||
errorMessageDiv.textContent = 'Failed to resend verification email. Please try again later.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
errorMessageDiv.textContent = 'An error occurred. Please try again later.';
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errorMessageDiv.textContent = 'An unknown error occurred';
|
||||
}
|
||||
} else {
|
||||
// Handle successful login
|
||||
alert('Login successful!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
const errorMessageDiv = document.getElementById('username-error');
|
||||
errorMessageDiv.classList.remove('hidden');
|
||||
errorMessageDiv.textContent = 'An error occurred. Please try again later.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
65
views/base/error.hbs
Normal file
65
views/base/error.hbs
Normal file
|
@ -0,0 +1,65 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error Page</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.video-bg {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: -1;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.video-bg, .overlay {
|
||||
display: none;
|
||||
}
|
||||
.error-prompt {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
position: static;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="relative flex items-center justify-start min-h-screen">
|
||||
{{#if background_image}}
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat" style="background-image: url('{{background_image}}')"></div>
|
||||
{{else}}
|
||||
<video autoplay muted loop class="video-bg">
|
||||
<source src="/assets/login.webm" type="video/webm">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{{/if}}
|
||||
<div class="overlay"></div>
|
||||
<div class="relative bg-gray-800 p-8 rounded-lg shadow-lg w-full max-w-md ml-16 error-prompt">
|
||||
<h2 class="text-2xl font-bold mb-2 text-white text-center">{{ error_header }}</h2>
|
||||
<p class="text-gray-400 mb-6 text-center">{{ error_message }}</p>
|
||||
{{#if button_name}}
|
||||
<div class="text-center">
|
||||
<button type="button" class="inline-block px-6 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-500 hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"><a href="{{ button_link }}">{{ button_name }}</a></button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in a new issue