add email verification after account registration

This commit is contained in:
Krrish Ghimire
2026-07-08 22:55:22 +05:45
parent 212785725f
commit 77c4cfc777
26 changed files with 1386 additions and 53 deletions

View File

@@ -9,6 +9,10 @@ export const routes: Routes = [
loadComponent: () => import('./auth/login/login.component').then(m => m.LoginComponent),
canActivate: [LoginGuard],
},
{
path: 'verify-email',
loadComponent: () => import('./auth/verify-email/verify-email.component').then(m => m.VerifyEmailComponent),
},
{
path: 'register',
loadComponent: () => import('./auth/register/register.component').then(m => m.RegisterComponent),

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -25,6 +25,10 @@ import { NgIf } from '@angular/common';
<p class="text-gray-500 mt-1">Sign in to your account</p>
</div>
<div *ngIf="showVerifiedMessage" class="bg-green-50 border border-green-200 text-green-700 text-sm rounded-lg p-3 mb-4">
Your account is ready. Please sign in.
</div>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" class="flex flex-col gap-4">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Email</mat-label>
@@ -41,6 +45,13 @@ import { NgIf } from '@angular/common';
<div *ngIf="error" class="text-red-600 text-sm">{{ error }}</div>
<div *ngIf="showResendLink" class="text-sm text-center">
<a (click)="resendVerification()" class="text-indigo-600 font-medium hover:underline cursor-pointer">
Resend verification email
</a>
<div *ngIf="resendMessage" class="text-green-600 mt-1">{{ resendMessage }}</div>
</div>
<button mat-flat-button color="primary" type="submit" [disabled]="loading" class="w-full py-2">
<mat-spinner *ngIf="loading" diameter="20" class="inline-block mr-2"></mat-spinner>
{{ loading ? 'Signing in...' : 'Sign In' }}
@@ -55,31 +66,63 @@ import { NgIf } from '@angular/common';
</div>
`,
})
export class LoginComponent {
export class LoginComponent implements OnInit {
loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
loading = false;
error = '';
showResendLink = false;
resendMessage = '';
showVerifiedMessage = false;
private unverifiedEmail = '';
constructor(
private fb: FormBuilder,
private authService: AuthService,
private router: Router,
private route: ActivatedRoute,
) {}
ngOnInit(): void {
if (this.route.snapshot.queryParams['verified'] === 'true') {
this.showVerifiedMessage = true;
}
}
onSubmit(): void {
if (this.loginForm.invalid) return;
this.loading = true;
this.error = '';
this.showResendLink = false;
this.resendMessage = '';
const { email, password } = this.loginForm.value;
this.authService.login(email!, password!).subscribe({
next: () => this.router.navigate(['/dashboard']),
error: (err) => {
this.error = err.error?.message || err.error?.error || 'Invalid email or password';
const msg = err.error?.detail || err.error?.message || 'Invalid email or password';
if (msg.toLowerCase().includes('verify your email')) {
this.error = 'Please verify your email address before logging in.';
this.showResendLink = true;
this.unverifiedEmail = email!;
} else {
this.error = msg;
}
this.loading = false;
},
});
}
resendVerification(): void {
if (!this.unverifiedEmail) return;
this.authService.resendVerification(this.unverifiedEmail).subscribe({
next: () => {
this.resendMessage = 'Verification email resent.';
},
error: () => {
this.resendMessage = 'Failed to resend. Please try again later.';
},
});
}
}

View File

@@ -83,7 +83,7 @@ export class RegisterComponent {
this.error = '';
const { name, email, password } = this.registerForm.value;
this.authService.register(name!, email!, password!).subscribe({
next: () => this.router.navigate(['/dashboard']),
next: () => this.router.navigate(['/verify-email'], { queryParams: { email: email! } }),
error: (err) => {
this.error = err.error?.message || err.error?.error || 'Registration failed';
this.loading = false;

View File

@@ -0,0 +1,226 @@
import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { NgIf } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthService } from '../../core/services/auth.service';
type PageState = 'check-email' | 'verifying' | 'verified' | 'set-password' | 'error';
@Component({
selector: 'app-verify-email',
standalone: true,
imports: [
RouterLink, ReactiveFormsModule, NgIf,
MatCardModule, MatFormFieldModule, MatInputModule, MatButtonModule,
MatProgressSpinnerModule,
],
template: `
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<mat-card class="w-full max-w-md p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-indigo-600">Indie</h1>
</div>
<div *ngIf="state === 'check-email'" class="text-center">
<div class="text-5xl mb-4">📧</div>
<h2 class="text-xl font-semibold text-gray-800 mb-2">Check your email</h2>
<p class="text-gray-500 mb-6">
We sent a verification link to <strong>{{ email }}</strong>
</p>
<p class="text-sm text-gray-400 mb-6">The link expires in 24 hours.</p>
<div *ngIf="resendMessage" class="text-green-600 text-sm mb-4">{{ resendMessage }}</div>
<div *ngIf="resendError" class="text-red-600 text-sm mb-4">{{ resendError }}</div>
<button mat-stroked-button (click)="resend()" [disabled]="resending" class="w-full mb-3">
<mat-spinner *ngIf="resending" diameter="16" class="inline-block mr-2"></mat-spinner>
{{ resending ? 'Sending...' : 'Resend verification email' }}
</button>
<a routerLink="/login" class="block text-sm text-indigo-600 font-medium hover:underline">Back to login</a>
</div>
<div *ngIf="state === 'verifying'" class="text-center">
<mat-spinner diameter="40" class="mx-auto mb-4"></mat-spinner>
<p class="text-gray-500">Verifying your email...</p>
</div>
<div *ngIf="state === 'verified'" class="text-center">
<div class="text-5xl mb-4">✅</div>
<h2 class="text-xl font-semibold text-gray-800 mb-2">Email verified!</h2>
<p class="text-gray-500 mb-6">Your email has been verified. Now set your password to activate your account.</p>
<button mat-flat-button color="primary" (click)="state = 'set-password'" class="w-full">
Set Your Password
</button>
</div>
<div *ngIf="state === 'set-password'">
<h2 class="text-xl font-semibold text-gray-800 mb-4 text-center">Set your password</h2>
<form [formGroup]="passwordForm" (ngSubmit)="onSetPassword()" class="flex flex-col gap-4">
<mat-form-field appearance="outline" class="w-full">
<mat-label>New Password</mat-label>
<input matInput type="password" formControlName="password" />
<mat-error *ngIf="passwordForm.get('password')?.hasError('required')">Password is required</mat-error>
<mat-error *ngIf="passwordForm.get('password')?.hasError('minlength')">At least 8 characters</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Confirm Password</mat-label>
<input matInput type="password" formControlName="confirmPassword" />
<mat-error *ngIf="passwordForm.get('confirmPassword')?.hasError('required')">Please confirm your password</mat-error>
<mat-error *ngIf="passwordForm.hasError('mismatch')">Passwords do not match</mat-error>
</mat-form-field>
<div *ngIf="passwordError" class="text-red-600 text-sm">{{ passwordError }}</div>
<button mat-flat-button color="primary" type="submit" [disabled]="passwordLoading || passwordForm.invalid" class="w-full py-2">
<mat-spinner *ngIf="passwordLoading" diameter="20" class="inline-block mr-2"></mat-spinner>
{{ passwordLoading ? 'Setting password...' : 'Activate Account' }}
</button>
</form>
</div>
<div *ngIf="state === 'error'" class="text-center">
<div class="text-5xl mb-4">❌</div>
<h2 class="text-xl font-semibold text-gray-800 mb-2">{{ errorTitle }}</h2>
<p class="text-gray-500 mb-6">{{ errorMessage }}</p>
<button *ngIf="showResend" mat-stroked-button (click)="resend()" [disabled]="resending" class="w-full mb-3">
{{ resending ? 'Sending...' : 'Resend verification email' }}
</button>
<a *ngIf="!showResend" routerLink="/register" class="block text-sm text-indigo-600 font-medium hover:underline mb-2">Create a new account</a>
<a routerLink="/login" class="block text-sm text-indigo-600 font-medium hover:underline">Back to login</a>
</div>
</mat-card>
</div>
`,
})
export class VerifyEmailComponent implements OnInit {
state: PageState = 'check-email';
email = '';
errorTitle = '';
errorMessage = '';
showResend = false;
resendMessage = '';
resendError = '';
resending = false;
private setupToken = '';
passwordLoading = false;
passwordError = '';
passwordForm = this.fb.group({
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
}, { validators: this.passwordsMatch });
constructor(
private route: ActivatedRoute,
private router: Router,
private authService: AuthService,
private fb: FormBuilder,
) {}
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
const token = params['token'];
const email = params['email'];
if (token) {
this.verify(token);
} else if (email) {
this.email = email;
this.state = 'check-email';
} else {
this.state = 'check-email';
}
});
}
private verify(token: string): void {
this.state = 'verifying';
this.authService.verifyEmail(token).subscribe({
next: (res) => {
if (res.setupToken) {
this.setupToken = res.setupToken;
this.state = 'verified';
} else {
this.state = 'verified';
}
},
error: (err) => {
const msg = err.error?.detail || err.error?.message || 'Verification failed';
if (msg.toLowerCase().includes('expired')) {
this.errorTitle = 'Link expired';
this.errorMessage = 'This verification link has expired. Request a new one.';
this.showResend = true;
} else if (msg.toLowerCase().includes('already been used')) {
this.errorTitle = 'Already verified';
this.errorMessage = 'This link has already been used. Your email may already be verified. Try logging in.';
this.showResend = false;
} else {
this.errorTitle = 'Verification failed';
this.errorMessage = msg;
this.showResend = true;
}
this.state = 'error';
},
});
}
resend(): void {
if (!this.email) {
const token = this.route.snapshot.queryParams['token'];
this.authService.verifyEmail(token).subscribe({
next: () => {},
error: (err) => {
this.email = err.error?.email || '';
},
});
return;
}
this.resending = true;
this.resendMessage = '';
this.resendError = '';
this.authService.resendVerification(this.email).subscribe({
next: () => {
this.resendMessage = 'Verification email resent. Please check your inbox.';
this.resending = false;
this.state = 'check-email';
},
error: () => {
this.resendError = 'Failed to resend. Please try again later.';
this.resending = false;
},
});
}
onSetPassword(): void {
if (this.passwordForm.invalid) return;
this.passwordLoading = true;
this.passwordError = '';
this.authService.setPassword(this.setupToken, this.passwordForm.value.password!).subscribe({
next: () => {
this.router.navigate(['/login'], { queryParams: { verified: 'true' } });
},
error: (err) => {
this.passwordError = err.error?.detail || err.error?.message || 'Failed to set password';
this.passwordLoading = false;
},
});
}
private passwordsMatch(group: any): { mismatch: boolean } | null {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { mismatch: true };
}
}

View File

@@ -14,9 +14,8 @@ export class AuthService {
constructor(private http: HttpClient) {}
register(name: string, email: string, password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>('/api/auth/register', { name, email, password })
.pipe(tap(res => this.setSession(res)));
register(name: string, email: string, password: string): Observable<{ message: string }> {
return this.http.post<{ message: string }>('/api/auth/register', { name, email, password });
}
login(email: string, password: string): Observable<AuthResponse> {
@@ -34,6 +33,18 @@ export class AuthService {
.pipe(tap(() => this.clearSession()));
}
verifyEmail(token: string): Observable<{ message: string; setupToken?: string }> {
return this.http.post<{ message: string; setupToken?: string }>('/api/auth/verify-email', { token });
}
resendVerification(email: string): Observable<{ message: string }> {
return this.http.post<{ message: string }>('/api/auth/resend-verification', { email });
}
setPassword(setupToken: string, password: string): Observable<void> {
return this.http.post<void>('/api/auth/set-password', { setupToken, password });
}
getAccessToken(): string | null {
return localStorage.getItem(this.ACCESS_TOKEN_KEY);
}
@@ -72,5 +83,4 @@ export class AuthService {
const data = localStorage.getItem(this.USER_KEY);
return data ? JSON.parse(data) : null;
}
}