feat: Implement password reset and email verification with Mailgun
Backend changes: - Add password reset token database migration (V011) - Create email service with Mailgun integration (EU/US regions) - Implement password reset flow with secure token generation - Add email verification endpoints and logic - Create beautiful HTML email templates for reset and verification - Add password reset DTOs with validation - Update User entity with email verification fields Frontend changes: - Create forgot password page with email submission - Create reset password page with token validation - Add email verification banner component - Integrate verification banner into main dashboard - Add password requirements and validation UI Features: - Mailgun API ready for EU and US regions - Secure token expiration (1h for reset, 24h for verification) - Rate limiting on resend (2min interval) - Protection against email enumeration - IP address and user agent tracking - Token reuse prevention 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
28
maternal-app/maternal-app-backend/package-lock.json
generated
28
maternal-app/maternal-app-backend/package-lock.json
generated
@@ -35,9 +35,11 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"form-data": "^4.0.4",
|
||||
"graphql": "^16.11.0",
|
||||
"ioredis": "^5.8.0",
|
||||
"langchain": "^0.3.35",
|
||||
"mailgun.js": "^12.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"openai": "^5.23.2",
|
||||
"passport": "^0.7.0",
|
||||
@@ -6978,6 +6980,12 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base-64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
|
||||
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -11471,6 +11479,20 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mailgun.js": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mailgun.js/-/mailgun.js-12.1.0.tgz",
|
||||
"integrity": "sha512-Cd6rD/aYosAuKcHI9iHdgF6n65nNB63ClQrpsvNVxDeks49SMOuh1euFXV/JUNB27n4jMZhprXSKnftcV2Bm9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.12.1",
|
||||
"base-64": "^1.0.0",
|
||||
"url-join": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
@@ -14840,6 +14862,12 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-join": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
|
||||
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@@ -47,9 +47,11 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"form-data": "^4.0.4",
|
||||
"graphql": "^16.11.0",
|
||||
"ioredis": "^5.8.0",
|
||||
"langchain": "^0.3.35",
|
||||
"mailgun.js": "^12.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"openai": "^5.23.2",
|
||||
"passport": "^0.7.0",
|
||||
|
||||
@@ -4,11 +4,12 @@ import { AuditLog } from '../database/entities';
|
||||
import { AuditService } from './services/audit.service';
|
||||
import { ErrorResponseService } from './services/error-response.service';
|
||||
import { CacheService } from './services/cache.service';
|
||||
import { EmailService } from './services/email.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AuditLog])],
|
||||
providers: [AuditService, ErrorResponseService, CacheService],
|
||||
exports: [AuditService, ErrorResponseService, CacheService],
|
||||
providers: [AuditService, ErrorResponseService, CacheService, EmailService],
|
||||
exports: [AuditService, ErrorResponseService, CacheService, EmailService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Mailgun from 'mailgun.js';
|
||||
import formData from 'form-data';
|
||||
|
||||
export interface EmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetEmailData {
|
||||
userName: string;
|
||||
resetLink: string;
|
||||
expiresIn: string;
|
||||
}
|
||||
|
||||
export interface EmailVerificationData {
|
||||
userName: string;
|
||||
verificationLink: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private readonly logger = new Logger(EmailService.name);
|
||||
private mailgunClient: any;
|
||||
private readonly fromEmail: string;
|
||||
private readonly fromName: string;
|
||||
private readonly appUrl: string;
|
||||
private readonly mailgunDomain: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const mailgunApiKey = this.configService.get<string>('MAILGUN_API_KEY');
|
||||
const mailgunRegion = this.configService.get<string>('MAILGUN_REGION', 'us'); // 'us' or 'eu'
|
||||
this.mailgunDomain = this.configService.get<string>('MAILGUN_DOMAIN', '');
|
||||
this.fromEmail = this.configService.get<string>('EMAIL_FROM', 'noreply@maternal-app.com');
|
||||
this.fromName = this.configService.get<string>('EMAIL_FROM_NAME', 'Maternal App');
|
||||
this.appUrl = this.configService.get<string>('APP_URL', 'http://localhost:3030');
|
||||
|
||||
// Initialize Mailgun client
|
||||
if (mailgunApiKey && this.mailgunDomain) {
|
||||
const mailgun = new Mailgun(formData);
|
||||
this.mailgunClient = mailgun.client({
|
||||
username: 'api',
|
||||
key: mailgunApiKey,
|
||||
url: mailgunRegion === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net',
|
||||
});
|
||||
this.logger.log(`Mailgun initialized for ${mailgunRegion.toUpperCase()} region`);
|
||||
} else {
|
||||
this.logger.warn('Mailgun not configured - emails will be logged but not sent');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a generic email
|
||||
*/
|
||||
async sendEmail(options: EmailOptions): Promise<void> {
|
||||
const { to, subject, html, text } = options;
|
||||
|
||||
if (!this.mailgunClient) {
|
||||
this.logger.warn(`[EMAIL NOT SENT - No Mailgun Config] To: ${to}, Subject: ${subject}`);
|
||||
this.logger.debug(`Email content: ${text || html.substring(0, 200)}...`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const messageData = {
|
||||
from: `${this.fromName} <${this.fromEmail}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text: text || this.stripHtml(html),
|
||||
};
|
||||
|
||||
await this.mailgunClient.messages.create(this.mailgunDomain, messageData);
|
||||
this.logger.log(`Email sent successfully to ${to}: ${subject}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send email to ${to}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async sendPasswordResetEmail(to: string, data: PasswordResetEmailData): Promise<void> {
|
||||
const subject = 'Reset Your Maternal App Password';
|
||||
const html = this.getPasswordResetEmailTemplate(data);
|
||||
|
||||
await this.sendEmail({ to, subject, html });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification email
|
||||
*/
|
||||
async sendEmailVerificationEmail(to: string, data: EmailVerificationData): Promise<void> {
|
||||
const subject = 'Verify Your Maternal App Email';
|
||||
const html = this.getEmailVerificationTemplate(data);
|
||||
|
||||
await this.sendEmail({ to, subject, html });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send welcome email after successful registration
|
||||
*/
|
||||
async sendWelcomeEmail(to: string, userName: string): Promise<void> {
|
||||
const subject = 'Welcome to Maternal App! 🎉';
|
||||
const html = this.getWelcomeEmailTemplate(userName);
|
||||
|
||||
await this.sendEmail({ to, subject, html });
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reset email template
|
||||
*/
|
||||
private getPasswordResetEmailTemplate(data: PasswordResetEmailData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reset Your Password</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 40px auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #FFB5A0 0%, #FF8B7D 100%); padding: 40px 20px; text-align: center; color: white; }
|
||||
.header h1 { margin: 0; font-size: 28px; font-weight: 600; }
|
||||
.content { padding: 40px 30px; }
|
||||
.content p { margin: 0 0 20px 0; font-size: 16px; }
|
||||
.button { display: inline-block; padding: 14px 32px; background: #FF8B7D; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 20px 0; }
|
||||
.button:hover { background: #FF7B6D; }
|
||||
.info-box { background: #FFF5F5; border-left: 4px solid #FFB5A0; padding: 15px; margin: 20px 0; border-radius: 4px; }
|
||||
.footer { background: #f9f9f9; padding: 20px 30px; text-align: center; font-size: 14px; color: #666; }
|
||||
.footer a { color: #FF8B7D; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Reset Your Password</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi ${data.userName},</p>
|
||||
<p>We received a request to reset your password for your Maternal App account. Click the button below to create a new password:</p>
|
||||
<div style="text-align: center;">
|
||||
<a href="${data.resetLink}" class="button">Reset Password</a>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<strong>⏱️ This link expires in ${data.expiresIn}</strong><br>
|
||||
If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
|
||||
</div>
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666; font-size: 14px;">${data.resetLink}</p>
|
||||
<p style="margin-top: 30px;">Best regards,<br>The Maternal App Team</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>If you have any questions, contact us at <a href="mailto:support@maternal-app.com">support@maternal-app.com</a></p>
|
||||
<p>© ${new Date().getFullYear()} Maternal App. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Email verification template
|
||||
*/
|
||||
private getEmailVerificationTemplate(data: EmailVerificationData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify Your Email</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 40px auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #FFB5A0 0%, #FF8B7D 100%); padding: 40px 20px; text-align: center; color: white; }
|
||||
.header h1 { margin: 0; font-size: 28px; font-weight: 600; }
|
||||
.content { padding: 40px 30px; }
|
||||
.content p { margin: 0 0 20px 0; font-size: 16px; }
|
||||
.button { display: inline-block; padding: 14px 32px; background: #FF8B7D; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 20px 0; }
|
||||
.button:hover { background: #FF7B6D; }
|
||||
.info-box { background: #E8F5E9; border-left: 4px solid #4CAF50; padding: 15px; margin: 20px 0; border-radius: 4px; }
|
||||
.footer { background: #f9f9f9; padding: 20px 30px; text-align: center; font-size: 14px; color: #666; }
|
||||
.footer a { color: #FF8B7D; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Verify Your Email 📧</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi ${data.userName},</p>
|
||||
<p>Thank you for signing up for Maternal App! To complete your registration and access all features, please verify your email address by clicking the button below:</p>
|
||||
<div style="text-align: center;">
|
||||
<a href="${data.verificationLink}" class="button">Verify Email</a>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<strong>✅ Why verify?</strong><br>
|
||||
Email verification helps us ensure your account security and enables us to send you important updates about your child's activities and milestones.
|
||||
</div>
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666; font-size: 14px;">${data.verificationLink}</p>
|
||||
<p style="margin-top: 30px;">Welcome to the Maternal App family! 🎉<br>The Maternal App Team</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
<p>Questions? Contact us at <a href="mailto:support@maternal-app.com">support@maternal-app.com</a></p>
|
||||
<p>© ${new Date().getFullYear()} Maternal App. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcome email template
|
||||
*/
|
||||
private getWelcomeEmailTemplate(userName: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Welcome to Maternal App</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 40px auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #FFB5A0 0%, #FF8B7D 100%); padding: 40px 20px; text-align: center; color: white; }
|
||||
.header h1 { margin: 0; font-size: 28px; font-weight: 600; }
|
||||
.content { padding: 40px 30px; }
|
||||
.content p { margin: 0 0 20px 0; font-size: 16px; }
|
||||
.feature-box { background: #FFF5F5; padding: 20px; margin: 15px 0; border-radius: 6px; border-left: 4px solid #FFB5A0; }
|
||||
.feature-box h3 { margin: 0 0 10px 0; color: #FF8B7D; font-size: 18px; }
|
||||
.button { display: inline-block; padding: 14px 32px; background: #FF8B7D; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 20px 0; }
|
||||
.footer { background: #f9f9f9; padding: 20px 30px; text-align: center; font-size: 14px; color: #666; }
|
||||
.footer a { color: #FF8B7D; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Welcome to Maternal App! 🎉</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi ${userName},</p>
|
||||
<p>We're thrilled to have you join the Maternal App family! Our mission is to help you track and understand your child's development, reduce your mental load, and provide AI-powered parenting support whenever you need it.</p>
|
||||
|
||||
<div class="feature-box">
|
||||
<h3>📊 Track Activities</h3>
|
||||
<p>Easily log feeding, sleep, diaper changes, and more with our intuitive interface.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-box">
|
||||
<h3>🤖 AI Parenting Assistant</h3>
|
||||
<p>Get personalized insights and answers to your parenting questions 24/7.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-box">
|
||||
<h3>👨👩👧 Family Sync</h3>
|
||||
<p>Keep everyone in the loop with real-time activity updates across all devices.</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="${this.appUrl}" class="button">Get Started</a>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 30px;">If you have any questions or need help getting started, our support team is here for you!</p>
|
||||
<p>Happy parenting! 💕<br>The Maternal App Team</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Questions? Contact us at <a href="mailto:support@maternal-app.com">support@maternal-app.com</a></p>
|
||||
<p>© ${new Date().getFullYear()} Maternal App. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags for plain text version
|
||||
*/
|
||||
private stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export { Family } from './family.entity';
|
||||
export { FamilyMember, FamilyRole, FamilyPermissions } from './family-member.entity';
|
||||
export { Child } from './child.entity';
|
||||
export { RefreshToken } from './refresh-token.entity';
|
||||
export { PasswordResetToken } from './password-reset-token.entity';
|
||||
export { AIConversation, MessageRole, ConversationMessage } from './ai-conversation.entity';
|
||||
export { Activity, ActivityType } from './activity.entity';
|
||||
export { AuditLog, AuditAction, EntityType } from './audit-log.entity';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
BeforeInsert,
|
||||
} from 'typeorm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('password_reset_tokens')
|
||||
export class PasswordResetToken {
|
||||
@PrimaryColumn({ type: 'varchar', length: 30 })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, name: 'user_id' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'varchar', length: 64, unique: true })
|
||||
token: string;
|
||||
|
||||
@Column({ type: 'timestamp without time zone', name: 'expires_at' })
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp without time zone', name: 'used_at', nullable: true })
|
||||
usedAt: Date | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 45, name: 'ip_address', nullable: true })
|
||||
ipAddress: string | null;
|
||||
|
||||
@Column({ type: 'text', name: 'user_agent', nullable: true })
|
||||
userAgent: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp without time zone', name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
generateId() {
|
||||
if (!this.id) {
|
||||
this.id = `reset_${nanoid(12)}`;
|
||||
}
|
||||
}
|
||||
|
||||
isExpired(): boolean {
|
||||
return new Date() > this.expiresAt;
|
||||
}
|
||||
|
||||
isUsed(): boolean {
|
||||
return this.usedAt !== null;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,12 @@ export class User {
|
||||
@Column({ name: 'email_verified', default: false })
|
||||
emailVerified: boolean;
|
||||
|
||||
@Column({ name: 'email_verification_token', length: 64, nullable: true })
|
||||
emailVerificationToken?: string | null;
|
||||
|
||||
@Column({ name: 'email_verification_sent_at', type: 'timestamp without time zone', nullable: true })
|
||||
emailVerificationSentAt?: Date | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
preferences?: {
|
||||
notifications?: boolean;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Migration: V011_create_password_reset_tokens
|
||||
-- Description: Create password reset tokens and email verification tracking
|
||||
|
||||
-- Password reset tokens table
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id VARCHAR(30) PRIMARY KEY,
|
||||
user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
used_at TIMESTAMP WITHOUT TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
||||
CREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token);
|
||||
CREATE INDEX idx_password_reset_tokens_expires ON password_reset_tokens(expires_at);
|
||||
|
||||
-- Add email verification fields to users table
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(64),
|
||||
ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMP WITHOUT TIME ZONE;
|
||||
|
||||
CREATE INDEX idx_users_email_verification_token ON users(email_verification_token);
|
||||
|
||||
-- Email verification history (for auditing)
|
||||
CREATE TABLE IF NOT EXISTS email_verification_logs (
|
||||
id VARCHAR(30) PRIMARY KEY,
|
||||
user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
verification_type VARCHAR(50) NOT NULL, -- 'signup', 'password_reset', 'email_change'
|
||||
token_sent_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
verified_at TIMESTAMP WITHOUT TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_email_verification_logs_user ON email_verification_logs(user_id);
|
||||
CREATE INDEX idx_email_verification_logs_email ON email_verification_logs(email);
|
||||
CREATE INDEX idx_email_verification_logs_created ON email_verification_logs(created_at DESC);
|
||||
@@ -8,19 +8,26 @@ import {
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
ValidationPipe,
|
||||
Ip,
|
||||
Headers,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PasswordResetService } from './password-reset.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { LogoutDto } from './dto/logout.dto';
|
||||
import { RequestPasswordResetDto, ResetPasswordDto, VerifyEmailDto } from './dto/password-reset.dto';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@Controller('api/v1/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly passwordResetService: PasswordResetService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@@ -69,4 +76,48 @@ export class AuthController {
|
||||
) {
|
||||
return await this.authService.updateProfile(user.userId, updateData);
|
||||
}
|
||||
|
||||
// Password Reset Endpoints
|
||||
@Public()
|
||||
@Post('password/forgot')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async forgotPassword(
|
||||
@Body(ValidationPipe) dto: RequestPasswordResetDto,
|
||||
@Ip() ip: string,
|
||||
@Headers('user-agent') userAgent: string,
|
||||
) {
|
||||
return await this.passwordResetService.requestPasswordReset(dto, ip, userAgent);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('password/reset')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resetPassword(
|
||||
@Body(ValidationPipe) dto: ResetPasswordDto,
|
||||
@Ip() ip: string,
|
||||
) {
|
||||
return await this.passwordResetService.resetPassword(dto, ip);
|
||||
}
|
||||
|
||||
// Email Verification Endpoints
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('email/send-verification')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async sendEmailVerification(@CurrentUser() user: any) {
|
||||
return await this.passwordResetService.sendEmailVerification(user.userId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('email/verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyEmail(@Body(ValidationPipe) dto: VerifyEmailDto) {
|
||||
return await this.passwordResetService.verifyEmail(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('email/resend-verification')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resendEmailVerification(@CurrentUser() user: any) {
|
||||
return await this.passwordResetService.resendEmailVerification(user.userId);
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,21 @@ import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PasswordResetService } from './password-reset.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import {
|
||||
User,
|
||||
DeviceRegistry,
|
||||
RefreshToken,
|
||||
PasswordResetToken,
|
||||
Family,
|
||||
FamilyMember,
|
||||
} from '../../database/entities';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, Family, FamilyMember]),
|
||||
TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, PasswordResetToken, Family, FamilyMember]),
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
@@ -31,7 +33,7 @@ import {
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, LocalStrategy],
|
||||
exports: [AuthService],
|
||||
providers: [AuthService, PasswordResetService, JwtStrategy, LocalStrategy],
|
||||
exports: [AuthService, PasswordResetService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength, Matches } from 'class-validator';
|
||||
|
||||
export class RequestPasswordResetDto {
|
||||
@IsEmail({}, { message: 'Please provide a valid email address' })
|
||||
@IsNotEmpty({ message: 'Email is required' })
|
||||
email: string;
|
||||
}
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@IsString({ message: 'Token is required' })
|
||||
@IsNotEmpty({ message: 'Token is required' })
|
||||
token: string;
|
||||
|
||||
@IsString({ message: 'Password must be a string' })
|
||||
@MinLength(8, { message: 'Password must be at least 8 characters long' })
|
||||
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
|
||||
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one number',
|
||||
})
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export class VerifyEmailDto {
|
||||
@IsString({ message: 'Token is required' })
|
||||
@IsNotEmpty({ message: 'Token is required' })
|
||||
token: string;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { User, PasswordResetToken } from '../../database/entities';
|
||||
import { EmailService } from '../../common/services/email.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { RequestPasswordResetDto, ResetPasswordDto, VerifyEmailDto } from './dto/password-reset.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PasswordResetService {
|
||||
private readonly logger = new Logger(PasswordResetService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
@InjectRepository(PasswordResetToken)
|
||||
private passwordResetTokenRepository: Repository<PasswordResetToken>,
|
||||
private emailService: EmailService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Request a password reset - sends email with reset link
|
||||
*/
|
||||
async requestPasswordReset(
|
||||
dto: RequestPasswordResetDto,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
// Always return success to prevent email enumeration attacks
|
||||
if (!user) {
|
||||
this.logger.warn(`Password reset requested for non-existent email: ${dto.email}`);
|
||||
return {
|
||||
success: true,
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
};
|
||||
}
|
||||
|
||||
// Generate secure random token
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 1); // Token expires in 1 hour
|
||||
|
||||
// Create password reset token
|
||||
const resetToken = this.passwordResetTokenRepository.create({
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
|
||||
await this.passwordResetTokenRepository.save(resetToken);
|
||||
|
||||
// Generate reset link
|
||||
const appUrl = this.configService.get<string>('APP_URL', 'http://localhost:3030');
|
||||
const resetLink = `${appUrl}/reset-password?token=${token}`;
|
||||
|
||||
// Send password reset email
|
||||
try {
|
||||
await this.emailService.sendPasswordResetEmail(user.email, {
|
||||
userName: user.name,
|
||||
resetLink,
|
||||
expiresIn: '1 hour',
|
||||
});
|
||||
|
||||
this.logger.log(`Password reset email sent to ${user.email}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send password reset email to ${user.email}:`, error);
|
||||
// Don't throw error to user - they'll get generic success message
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'If an account with that email exists, a password reset link has been sent.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password using token
|
||||
*/
|
||||
async resetPassword(
|
||||
dto: ResetPasswordDto,
|
||||
ipAddress?: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
// Find the reset token
|
||||
const resetToken = await this.passwordResetTokenRepository.findOne({
|
||||
where: { token: dto.token },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (!resetToken) {
|
||||
throw new BadRequestException('Invalid or expired password reset token');
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (resetToken.isExpired()) {
|
||||
throw new BadRequestException('Password reset token has expired. Please request a new one.');
|
||||
}
|
||||
|
||||
// Check if token was already used
|
||||
if (resetToken.isUsed()) {
|
||||
throw new BadRequestException('This password reset token has already been used');
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(dto.newPassword, saltRounds);
|
||||
|
||||
// Update user's password
|
||||
resetToken.user.passwordHash = passwordHash;
|
||||
await this.userRepository.save(resetToken.user);
|
||||
|
||||
// Mark token as used
|
||||
resetToken.usedAt = new Date();
|
||||
await this.passwordResetTokenRepository.save(resetToken);
|
||||
|
||||
this.logger.log(`Password reset successful for user ${resetToken.user.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Your password has been reset successfully. You can now log in with your new password.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification link
|
||||
*/
|
||||
async sendEmailVerification(
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Your email is already verified',
|
||||
};
|
||||
}
|
||||
|
||||
// Generate verification token
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Store token in user record
|
||||
user.emailVerificationToken = token;
|
||||
user.emailVerificationSentAt = new Date();
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// Generate verification link
|
||||
const appUrl = this.configService.get<string>('APP_URL', 'http://localhost:3030');
|
||||
const verificationLink = `${appUrl}/verify-email?token=${token}`;
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await this.emailService.sendEmailVerificationEmail(user.email, {
|
||||
userName: user.name,
|
||||
verificationLink,
|
||||
});
|
||||
|
||||
this.logger.log(`Email verification sent to ${user.email}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send verification email to ${user.email}:`, error);
|
||||
throw new BadRequestException('Failed to send verification email. Please try again later.');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Verification email sent. Please check your inbox.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email using token
|
||||
*/
|
||||
async verifyEmail(
|
||||
dto: VerifyEmailDto,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { emailVerificationToken: dto.token },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid or expired verification token');
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Your email is already verified',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if token is too old (expires after 24 hours)
|
||||
const tokenAge = new Date().getTime() - user.emailVerificationSentAt.getTime();
|
||||
const maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
|
||||
if (tokenAge > maxAge) {
|
||||
throw new BadRequestException('Verification token has expired. Please request a new one.');
|
||||
}
|
||||
|
||||
// Mark email as verified
|
||||
user.emailVerified = true;
|
||||
user.emailVerificationToken = null;
|
||||
user.emailVerificationSentAt = null;
|
||||
await this.userRepository.save(user);
|
||||
|
||||
this.logger.log(`Email verified for user ${user.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Your email has been verified successfully!',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend email verification
|
||||
*/
|
||||
async resendEmailVerification(
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Your email is already verified',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we recently sent a verification email (rate limiting)
|
||||
if (user.emailVerificationSentAt) {
|
||||
const timeSinceLastSent = new Date().getTime() - user.emailVerificationSentAt.getTime();
|
||||
const minInterval = 2 * 60 * 1000; // 2 minutes in milliseconds
|
||||
|
||||
if (timeSinceLastSent < minInterval) {
|
||||
throw new BadRequestException('Please wait at least 2 minutes before requesting another verification email');
|
||||
}
|
||||
}
|
||||
|
||||
return await this.sendEmailVerification(userId);
|
||||
}
|
||||
}
|
||||
245
maternal-web/app/(auth)/forgot-password/page.tsx
Normal file
245
maternal-web/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Link as MuiLink,
|
||||
} from '@mui/material';
|
||||
import { Email, ArrowBack } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
setError('Please enter your email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/v1/auth/password/forgot', { email });
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
console.error('Forgot password error:', err);
|
||||
setError(err.response?.data?.message || 'Failed to send reset email. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
maxWidth: 480,
|
||||
width: '100%',
|
||||
p: 4,
|
||||
borderRadius: 4,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
{!success ? (
|
||||
<>
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'primary.light',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Email sx={{ fontSize: 32, color: 'primary.main' }} />
|
||||
</Box>
|
||||
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||
Forgot Password?
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No worries! Enter your email address and we'll send you a link to reset your password.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
required
|
||||
autoFocus
|
||||
sx={{
|
||||
mb: 3,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={loading}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} color="inherit" /> : 'Send Reset Link'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<MuiLink
|
||||
component={Link}
|
||||
href="/login"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ArrowBack sx={{ fontSize: 18 }} />
|
||||
Back to Login
|
||||
</MuiLink>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'success.light',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Email sx={{ fontSize: 32, color: 'success.main' }} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" fontWeight="600" gutterBottom>
|
||||
Check Your Email 📧
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
If an account with that email exists, we've sent you a password reset link. Please check your inbox and follow the instructions.
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3, borderRadius: 2, textAlign: 'left' }}>
|
||||
<Typography variant="body2" fontWeight="600" gutterBottom>
|
||||
Didn't receive the email?
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Check your spam or junk folder
|
||||
<br />
|
||||
• Make sure you entered the correct email
|
||||
<br />
|
||||
• The link expires in 1 hour
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setSuccess(false);
|
||||
setEmail('');
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Try Another Email
|
||||
</Button>
|
||||
|
||||
<MuiLink
|
||||
component={Link}
|
||||
href="/login"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ArrowBack sx={{ fontSize: 18 }} />
|
||||
Back to Login
|
||||
</MuiLink>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
335
maternal-web/app/(auth)/reset-password/page.tsx
Normal file
335
maternal-web/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Link as MuiLink,
|
||||
} from '@mui/material';
|
||||
import { LockReset, Visibility, VisibilityOff, CheckCircle } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [token, setToken] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const tokenParam = searchParams.get('token');
|
||||
if (tokenParam) {
|
||||
setToken(tokenParam);
|
||||
} else {
|
||||
setError('Invalid or missing reset token');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const validatePassword = (password: string): string | null => {
|
||||
if (password.length < 8) {
|
||||
return 'Password must be at least 8 characters long';
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
return 'Password must contain at least one lowercase letter';
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
return 'Password must contain at least one uppercase letter';
|
||||
}
|
||||
if (!/\d/.test(password)) {
|
||||
return 'Password must contain at least one number';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!newPassword.trim()) {
|
||||
setError('Please enter a new password');
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordError = validatePassword(newPassword);
|
||||
if (passwordError) {
|
||||
setError(passwordError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/v1/auth/password/reset', {
|
||||
token,
|
||||
newPassword,
|
||||
});
|
||||
setSuccess(true);
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
console.error('Reset password error:', err);
|
||||
setError(
|
||||
err.response?.data?.message || 'Failed to reset password. The link may have expired.'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token && !error) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
maxWidth: 480,
|
||||
width: '100%',
|
||||
p: 4,
|
||||
borderRadius: 4,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
{!success ? (
|
||||
<>
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'primary.light',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<LockReset sx={{ fontSize: 32, color: 'primary.main' }} />
|
||||
</Box>
|
||||
<Typography variant="h4" fontWeight="600" gutterBottom>
|
||||
Reset Password
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Enter your new password below
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="New Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
required
|
||||
autoFocus
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Confirm New Password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
required
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
<Typography variant="body2" fontWeight="600" gutterBottom>
|
||||
Password Requirements:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
• At least 8 characters long
|
||||
<br />
|
||||
• One uppercase letter (A-Z)
|
||||
<br />
|
||||
• One lowercase letter (a-z)
|
||||
<br />• One number (0-9)
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={loading || !token}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} color="inherit" /> : 'Reset Password'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<MuiLink
|
||||
component={Link}
|
||||
href="/login"
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Back to Login
|
||||
</MuiLink>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'success.light',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<CheckCircle sx={{ fontSize: 48, color: 'success.main' }} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" fontWeight="600" gutterBottom>
|
||||
Password Reset Successful! 🎉
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Your password has been reset successfully. You can now log in with your new password.
|
||||
</Typography>
|
||||
|
||||
<Alert severity="success" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
Redirecting to login page...
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
component={Link}
|
||||
href="/login"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Button, Paper, Grid, CircularProgress } from '@mui/material';
|
||||
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { EmailVerificationBanner } from '@/components/common/EmailVerificationBanner';
|
||||
import {
|
||||
Restaurant,
|
||||
Hotel,
|
||||
@@ -84,6 +85,8 @@ export default function HomePage() {
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Box>
|
||||
<EmailVerificationBanner />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
||||
117
maternal-web/components/common/EmailVerificationBanner.tsx
Normal file
117
maternal-web/components/common/EmailVerificationBanner.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, Snackbar, Box } from '@mui/material';
|
||||
import { Email } from '@mui/icons-material';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
export const EmailVerificationBanner: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'success',
|
||||
});
|
||||
|
||||
// Don't show if user is not logged in, email is verified, or banner was dismissed
|
||||
if (!user || user.emailVerified || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiClient.post('/api/v1/auth/email/send-verification');
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Verification email sent! Please check your inbox.',
|
||||
severity: 'success',
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to resend verification email:', error);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: error.response?.data?.message || 'Failed to send verification email. Please try again.',
|
||||
severity: 'error',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissed(true);
|
||||
// Store dismissal in localStorage to persist across sessions
|
||||
localStorage.setItem('emailVerificationBannerDismissed', 'true');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
severity="warning"
|
||||
icon={<Email />}
|
||||
onClose={handleDismiss}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 2,
|
||||
'& .MuiAlert-message': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<strong>Verify your email address</strong>
|
||||
<br />
|
||||
Please check your inbox and click the verification link to access all features.
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleResendVerification}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderColor: 'warning.main',
|
||||
color: 'warning.dark',
|
||||
'&:hover': {
|
||||
borderColor: 'warning.dark',
|
||||
bgcolor: 'warning.light',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? 'Sending...' : 'Resend Email'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity={snackbar.severity}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user