Compare commits
5 Commits
3e215ae8e8
...
6f2dd0fc01
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f2dd0fc01 | |||
| 623f0164c8 | |||
| 88b47099b1 | |||
| 659d2a0840 | |||
| 68950dba10 |
Vendored
+5
@@ -15,6 +15,11 @@ export declare class TourRoleGuard implements CanActivate {
|
||||
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
|
||||
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||
}
|
||||
export declare class EmailService {
|
||||
private transporter;
|
||||
constructor();
|
||||
sendOTP(email: string, otp: string): Promise<any>;
|
||||
}
|
||||
export declare class CommentGateway implements OnGatewayConnection {
|
||||
server: Server;
|
||||
handleConnection(client: Socket): void;
|
||||
|
||||
Vendored
+228
-19
@@ -48,13 +48,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CommentGateway = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
|
||||
const dotenv = __importStar(require("dotenv"));
|
||||
exports.CommentGateway = exports.EmailService = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
|
||||
const path = __importStar(require("path"));
|
||||
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
require("reflect-metadata");
|
||||
const fs = __importStar(require("fs"));
|
||||
const config_1 = require("@nestjs/config");
|
||||
require("reflect-metadata");
|
||||
const zlib = __importStar(require("zlib"));
|
||||
const util_1 = require("util");
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
@@ -67,6 +65,7 @@ const prisma_service_1 = require("../prisma/prisma.service");
|
||||
const client_1 = require("@prisma/client");
|
||||
const bcrypt = __importStar(require("bcrypt"));
|
||||
const admin_guard_1 = require("./auth/admin.guard");
|
||||
const nodemailer = __importStar(require("nodemailer"));
|
||||
const jwt_1 = require("@nestjs/jwt");
|
||||
const jwt_auth_guard_1 = require("./auth/jwt-auth.guard");
|
||||
const jwt_strategy_1 = require("./auth/jwt.strategy");
|
||||
@@ -84,12 +83,6 @@ const CACHE_TTL = {
|
||||
USER_ROLE: 300000,
|
||||
};
|
||||
async function bootstrap() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('❌ DATABASE_URL không tồn tại trong biến môi trường! Kiểm tra file .env tại: ' + envPath);
|
||||
}
|
||||
console.log('====================================');
|
||||
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||
console.log('====================================');
|
||||
const app = await core_1.NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableCors();
|
||||
@@ -202,6 +195,68 @@ exports.TourRoleGuard = TourRoleGuard = __decorate([
|
||||
__metadata("design:paramtypes", [core_2.Reflector,
|
||||
prisma_service_1.PrismaService, Object])
|
||||
], TourRoleGuard);
|
||||
let EmailService = class EmailService {
|
||||
constructor() {
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
if (!user || !pass) {
|
||||
console.warn('[EmailService] ⚠️ SMTP_USER hoặc SMTP_PASS chưa được cấu hình trong file .env. Tính năng gửi mã OTP sẽ không khả dụng.');
|
||||
}
|
||||
else {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: user,
|
||||
pass: pass,
|
||||
},
|
||||
});
|
||||
this.transporter.verify((error) => {
|
||||
if (error) {
|
||||
console.error('[EmailService] ❌ Lỗi kết nối SMTP:', error.message);
|
||||
}
|
||||
else {
|
||||
console.log('[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
async sendOTP(email, otp) {
|
||||
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
|
||||
throw new Error('Thiếu cấu hình SMTP_USER hoặc SMTP_PASS trong file .env');
|
||||
}
|
||||
const mailOptions = {
|
||||
from: `"Travel Planning Support" <${process.env.SMTP_USER}>`,
|
||||
to: email,
|
||||
subject: 'Mã OTP Xác Thực Hệ Thống',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 10px;">
|
||||
<h2 style="color: #2563eb; text-align: center;">Mã Xác Thực OTP</h2>
|
||||
<p>Xin chào,</p>
|
||||
<p>Quản trị viên hệ thống đã yêu cầu cấp và gửi mã OTP xác thực tới tài khoản của bạn. Vui lòng sử dụng mã bảo mật dưới đây:</p>
|
||||
<div style="background-color: #f3f4f6; padding: 15px; text-align: center; font-size: 24px; font-weight: bold; letter-spacing: 5px; color: #1e3a8a; margin: 20px 0; border-radius: 5px;">
|
||||
${otp}
|
||||
</div>
|
||||
<p>Mã OTP này có hiệu lực trong vòng 5 phút. Vui lòng tuyệt đối không chia sẻ mã này cho bất kỳ ai khác.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
|
||||
<p style="font-size: 12px; color: #6b7280; text-align: center;">Đây là email tự động từ ứng dụng hệ thống Travel Planning.</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
try {
|
||||
return await this.transporter.sendMail(mailOptions);
|
||||
}
|
||||
catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
exports.EmailService = EmailService;
|
||||
exports.EmailService = EmailService = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [])
|
||||
], EmailService);
|
||||
let AppController = class AppController {
|
||||
getHello() {
|
||||
return 'Travel Planning API is running!';
|
||||
@@ -217,9 +272,12 @@ AppController = __decorate([
|
||||
(0, common_1.Controller)()
|
||||
], AppController);
|
||||
let AuthController = class AuthController {
|
||||
constructor(prisma, jwtService) {
|
||||
constructor(prisma, jwtService, configService, emailService, cacheManager) {
|
||||
this.prisma = prisma;
|
||||
this.jwtService = jwtService;
|
||||
this.configService = configService;
|
||||
this.emailService = emailService;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
async getStatus() {
|
||||
const userCount = await this.prisma.user.count();
|
||||
@@ -246,18 +304,46 @@ let AuthController = class AuthController {
|
||||
},
|
||||
};
|
||||
}
|
||||
async signup(body) {
|
||||
async signupRequest(body) {
|
||||
const { email, password, name, phone, address } = body;
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existingUser)
|
||||
throw new common_1.BadRequestException('Email đã được sử dụng');
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
await this.cacheManager.set(`signup_otp:${email}`, otp, 300000);
|
||||
await this.cacheManager.set(`signup_data:${email}`, JSON.stringify({ password, name, phone, address }), 300000);
|
||||
try {
|
||||
await this.emailService.sendOTP(email, otp);
|
||||
return { success: true, message: 'Mã OTP đã được gửi đến email của bạn.' };
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[Signup OTP] Error:', error);
|
||||
throw new common_1.BadRequestException('Không thể gửi mã xác thực tới email này. Vui lòng kiểm tra lại cấu hình SMTP.');
|
||||
}
|
||||
}
|
||||
async signupVerify(body) {
|
||||
const { email, otp } = body;
|
||||
const storedOtp = await this.cacheManager.get(`signup_otp:${email}`);
|
||||
if (!storedOtp || storedOtp !== otp) {
|
||||
throw new common_1.BadRequestException('Mã OTP không chính xác hoặc đã hết hạn.');
|
||||
}
|
||||
const cachedDataStr = await this.cacheManager.get(`signup_data:${email}`);
|
||||
if (!cachedDataStr) {
|
||||
throw new common_1.BadRequestException('Thông tin đăng ký đã hết hạn. Vui lòng thực hiện lại từ đầu.');
|
||||
}
|
||||
const { password, name, phone, address } = JSON.parse(cachedDataStr);
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
return this.prisma.user.create({
|
||||
const user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
await Promise.all([
|
||||
this.cacheManager.del(`signup_otp:${email}`),
|
||||
this.cacheManager.del(`signup_data:${email}`)
|
||||
]);
|
||||
return user;
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
@@ -274,15 +360,26 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], AuthController.prototype, "login", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('signup'),
|
||||
(0, common_1.Post)('signup/request'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AuthController.prototype, "signup", null);
|
||||
], AuthController.prototype, "signupRequest", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('signup/verify'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AuthController.prototype, "signupVerify", null);
|
||||
AuthController = __decorate([
|
||||
(0, common_1.Controller)('auth'),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService, jwt_1.JwtService])
|
||||
__param(4, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
jwt_1.JwtService,
|
||||
config_1.ConfigService,
|
||||
EmailService, Object])
|
||||
], AuthController);
|
||||
let PublicTourController = class PublicTourController {
|
||||
constructor(prisma) {
|
||||
@@ -1593,11 +1690,120 @@ CommentController = __decorate([
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
CommentGateway])
|
||||
], CommentController);
|
||||
let AdminOtpController = class AdminOtpController {
|
||||
constructor(prisma, emailService, cacheManager) {
|
||||
this.prisma = prisma;
|
||||
this.emailService = emailService;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
async sendOtpToUser(body, req) {
|
||||
const { email } = body;
|
||||
const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
|
||||
const emailLimitKey = `rl:otp:email:${email}`;
|
||||
const ipLimitKey = `rl:otp:ip:${clientIp}`;
|
||||
const [isEmailLimited, isIpLimited] = await Promise.all([
|
||||
this.cacheManager.get(emailLimitKey),
|
||||
this.cacheManager.get(ipLimitKey)
|
||||
]);
|
||||
if (isEmailLimited || isIpLimited) {
|
||||
throw new common_1.HttpException('Thao tác quá nhanh. Vui lòng đợi 60 giây giữa mỗi lần yêu cầu gửi mã.', common_1.HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw new common_1.NotFoundException('Không tìm thấy người dùng với email này');
|
||||
}
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const otpCacheKey = `otp:${email}`;
|
||||
await this.cacheManager.set(otpCacheKey, otp, 300000);
|
||||
try {
|
||||
await this.emailService.sendOTP(email, otp);
|
||||
await Promise.all([
|
||||
this.cacheManager.set(emailLimitKey, true, 60000),
|
||||
this.cacheManager.set(ipLimitKey, true, 60000)
|
||||
]);
|
||||
return { success: true, message: `Đã gửi mã OTP tới email ${email} thành công.` };
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[Admin OTP] SMTP Error:', error);
|
||||
throw new common_1.BadRequestException('Lỗi cấu hình SMTP hoặc không thể kết nối tới máy chủ gửi mail');
|
||||
}
|
||||
}
|
||||
async verifyOtp(body) {
|
||||
const { email, otp } = body;
|
||||
const otpCacheKey = `otp:${email}`;
|
||||
const failCountKey = `otp_fails:${email}`;
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user)
|
||||
throw new common_1.NotFoundException('Người dùng không tồn tại');
|
||||
if (user.isBlocked) {
|
||||
throw new common_1.ForbiddenException('Tài khoản này hiện đang bị khóa. Vui lòng liên hệ quản trị viên.');
|
||||
}
|
||||
const storedOtp = await this.cacheManager.get(otpCacheKey);
|
||||
if (!storedOtp) {
|
||||
throw new common_1.BadRequestException('Mã OTP đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu mã mới.');
|
||||
}
|
||||
if (storedOtp === otp) {
|
||||
await Promise.all([
|
||||
this.cacheManager.del(otpCacheKey),
|
||||
this.cacheManager.del(failCountKey)
|
||||
]);
|
||||
return { success: true, message: 'Xác thực mã OTP thành công.' };
|
||||
}
|
||||
else {
|
||||
let fails = (await this.cacheManager.get(failCountKey)) || 0;
|
||||
fails++;
|
||||
if (fails >= MAX_FAILED_ATTEMPTS) {
|
||||
await this.prisma.user.update({
|
||||
where: { email },
|
||||
data: { isBlocked: true }
|
||||
});
|
||||
await this.cacheManager.del(failCountKey);
|
||||
await this.cacheManager.del(otpCacheKey);
|
||||
throw new common_1.ForbiddenException(`Bạn đã nhập sai quá ${MAX_FAILED_ATTEMPTS} lần. Tài khoản đã bị khóa để bảo mật.`);
|
||||
}
|
||||
else {
|
||||
await this.cacheManager.set(failCountKey, fails, 300000);
|
||||
throw new common_1.BadRequestException({
|
||||
message: `Mã OTP không chính xác. Bạn còn ${MAX_FAILED_ATTEMPTS - fails} lần thử.`,
|
||||
remainingAttempts: MAX_FAILED_ATTEMPTS - fails
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Post)('send'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AdminOtpController.prototype, "sendOtpToUser", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('verify'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AdminOtpController.prototype, "verifyOtp", null);
|
||||
AdminOtpController = __decorate([
|
||||
(0, common_1.Controller)('admin/otp'),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard),
|
||||
__param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
EmailService, Object])
|
||||
], AdminOtpController);
|
||||
let AppModule = class AppModule {
|
||||
};
|
||||
AppModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
imports: [
|
||||
config_1.ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: path.resolve(__dirname, '..', '..', '.env'),
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'production',
|
||||
}),
|
||||
cache_manager_1.CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
useFactory: async () => ({
|
||||
@@ -1612,12 +1818,15 @@ AppModule = __decorate([
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
|
||||
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
|
||||
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector, EmailService, config_1.ConfigService],
|
||||
exports: [prisma_service_1.PrismaService]
|
||||
})
|
||||
], AppModule);
|
||||
bootstrap().catch(err => {
|
||||
if (err.message.includes('DATABASE_URL')) {
|
||||
console.error('❌ Lỗi nghiêm trọng: Biến môi trường DATABASE_URL không được tải. Hãy chắc chắn rằng file .env tồn tại ở thư mục gốc của dự án và chứa giá trị này.');
|
||||
}
|
||||
console.error('💥 Lỗi khởi động Server:');
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^3.1.3",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
@@ -33,6 +34,7 @@
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"nodemailer": "^9.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.12.0",
|
||||
|
||||
+244
-21
@@ -1,17 +1,13 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// Sửa đường dẫn: lùi 2 cấp từ backend/src để ra root monorepo
|
||||
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
import * as fs from 'fs';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
|
||||
import 'reflect-metadata';
|
||||
import * as fs from 'fs';
|
||||
import * as zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import sharp from 'sharp';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject } from '@nestjs/common';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
@@ -21,6 +17,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ParticipantRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AdminGuard } from './auth/admin.guard';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { JwtStrategy } from './auth/jwt.strategy';
|
||||
@@ -47,14 +44,6 @@ const CACHE_TTL = {
|
||||
};
|
||||
|
||||
async function bootstrap() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('❌ DATABASE_URL không tồn tại trong biến môi trường! Kiểm tra file .env tại: ' + envPath);
|
||||
}
|
||||
|
||||
// Kiểm tra log xem biến môi trường đã nhận đúng chưa
|
||||
console.log('====================================');
|
||||
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||
console.log('====================================');
|
||||
|
||||
// Chuyển sang dùng NestExpressApplication để cấu hình static assets
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
@@ -187,6 +176,70 @@ export class TourRoleGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private transporter: any;
|
||||
|
||||
constructor() {
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
|
||||
if (!user || !pass) {
|
||||
console.warn('[EmailService] ⚠️ SMTP_USER hoặc SMTP_PASS chưa được cấu hình trong file .env. Tính năng gửi mã OTP sẽ không khả dụng.');
|
||||
} else {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_SECURE === 'true', // true cho cổng 465, false cho cổng 587/25
|
||||
auth: {
|
||||
user: user,
|
||||
pass: pass,
|
||||
},
|
||||
});
|
||||
|
||||
// Tự động kiểm tra kết nối khi khởi tạo để phát hiện lỗi cấu hình sớm
|
||||
this.transporter.verify((error: any) => {
|
||||
if (error) {
|
||||
console.error('[EmailService] ❌ Lỗi kết nối SMTP:', error.message);
|
||||
} else {
|
||||
console.log('[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendOTP(email: string, otp: string) {
|
||||
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
|
||||
throw new Error('Thiếu cấu hình SMTP_USER hoặc SMTP_PASS trong file .env');
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: `"Travel Planning Support" <${process.env.SMTP_USER}>`,
|
||||
to: email,
|
||||
subject: 'Mã OTP Xác Thực Hệ Thống',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 10px;">
|
||||
<h2 style="color: #2563eb; text-align: center;">Mã Xác Thực OTP</h2>
|
||||
<p>Xin chào,</p>
|
||||
<p>Quản trị viên hệ thống đã yêu cầu cấp và gửi mã OTP xác thực tới tài khoản của bạn. Vui lòng sử dụng mã bảo mật dưới đây:</p>
|
||||
<div style="background-color: #f3f4f6; padding: 15px; text-align: center; font-size: 24px; font-weight: bold; letter-spacing: 5px; color: #1e3a8a; margin: 20px 0; border-radius: 5px;">
|
||||
${otp}
|
||||
</div>
|
||||
<p>Mã OTP này có hiệu lực trong vòng 5 phút. Vui lòng tuyệt đối không chia sẻ mã này cho bất kỳ ai khác.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
|
||||
<p style="font-size: 12px; color: #6b7280; text-align: center;">Đây là email tự động từ ứng dụng hệ thống Travel Planning.</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
try {
|
||||
return await this.transporter.sendMail(mailOptions);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
class AppController {
|
||||
@Get()
|
||||
@@ -197,7 +250,17 @@ class AppController {
|
||||
|
||||
@Controller('auth')
|
||||
class AuthController {
|
||||
constructor(private prisma: PrismaService, private jwtService: JwtService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
// Inject ConfigService để đọc biến môi trường một cách an toàn
|
||||
// NestJS sẽ đảm bảo ConfigModule được tải trước khi AuthController được khởi tạo
|
||||
// Do đó, các biến môi trường sẽ luôn có sẵn ở đây.
|
||||
// Điều này cũng áp dụng cho EmailService.
|
||||
private configService: ConfigService,
|
||||
private emailService: EmailService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
) {}
|
||||
|
||||
@Get('status')
|
||||
async getStatus() {
|
||||
@@ -233,22 +296,60 @@ class AuthController {
|
||||
};
|
||||
}
|
||||
|
||||
@Post('signup')
|
||||
async signup(@Body() body: any) {
|
||||
@Post('signup/request')
|
||||
async signupRequest(@Body() body: any) {
|
||||
const { email, password, name, phone, address } = body;
|
||||
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existingUser) throw new BadRequestException('Email đã được sử dụng');
|
||||
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
|
||||
// Lưu OTP và dữ liệu form đăng ký vào Cache trong vòng 5 phút (300000 ms)
|
||||
await this.cacheManager.set(`signup_otp:${email}`, otp, 300000);
|
||||
await this.cacheManager.set(`signup_data:${email}`, JSON.stringify({ password, name, phone, address }), 300000);
|
||||
|
||||
try {
|
||||
await this.emailService.sendOTP(email, otp);
|
||||
return { success: true, message: 'Mã OTP đã được gửi đến email của bạn.' };
|
||||
} catch (error) {
|
||||
console.error('[Signup OTP] Error:', error);
|
||||
throw new BadRequestException('Không thể gửi mã xác thực tới email này. Vui lòng kiểm tra lại cấu hình SMTP.');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('signup/verify')
|
||||
async signupVerify(@Body() body: { email: string; otp: string }) {
|
||||
const { email, otp } = body;
|
||||
|
||||
const storedOtp = await this.cacheManager.get<string>(`signup_otp:${email}`);
|
||||
if (!storedOtp || storedOtp !== otp) {
|
||||
throw new BadRequestException('Mã OTP không chính xác hoặc đã hết hạn.');
|
||||
}
|
||||
|
||||
const cachedDataStr = await this.cacheManager.get<string>(`signup_data:${email}`);
|
||||
if (!cachedDataStr) {
|
||||
throw new BadRequestException('Thông tin đăng ký đã hết hạn. Vui lòng thực hiện lại từ đầu.');
|
||||
}
|
||||
|
||||
const { password, name, phone, address } = JSON.parse(cachedDataStr);
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
return this.prisma.user.create({
|
||||
const user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
|
||||
// Xóa dữ liệu cache sau khi đăng ký thành công
|
||||
await Promise.all([
|
||||
this.cacheManager.del(`signup_otp:${email}`),
|
||||
this.cacheManager.del(`signup_data:${email}`)
|
||||
]);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1480,8 +1581,127 @@ class CommentController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/otp')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
class AdminOtpController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
) {}
|
||||
|
||||
@Post('send')
|
||||
async sendOtpToUser(@Body() body: { email: string }, @Req() req: any) {
|
||||
const { email } = body;
|
||||
|
||||
// 1. Xác định định danh: IP và Email
|
||||
const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
|
||||
const emailLimitKey = `rl:otp:email:${email}`;
|
||||
const ipLimitKey = `rl:otp:ip:${clientIp}`;
|
||||
|
||||
// 2. Kiểm tra xem IP hoặc Email này có đang bị giới hạn không (ví dụ: 1 phút 1 lần)
|
||||
const [isEmailLimited, isIpLimited] = await Promise.all([
|
||||
this.cacheManager.get(emailLimitKey),
|
||||
this.cacheManager.get(ipLimitKey)
|
||||
]);
|
||||
|
||||
if (isEmailLimited || isIpLimited) {
|
||||
throw new HttpException(
|
||||
'Thao tác quá nhanh. Vui lòng đợi 60 giây giữa mỗi lần yêu cầu gửi mã.',
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Không tìm thấy người dùng với email này');
|
||||
}
|
||||
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const otpCacheKey = `otp:${email}`;
|
||||
|
||||
await this.cacheManager.set(otpCacheKey, otp, 300000);
|
||||
|
||||
try {
|
||||
await this.emailService.sendOTP(email, otp);
|
||||
|
||||
// 3. Thiết lập khóa chặn sau khi gửi thành công (hết hạn sau 60 giây)
|
||||
await Promise.all([
|
||||
this.cacheManager.set(emailLimitKey, true, 60000),
|
||||
this.cacheManager.set(ipLimitKey, true, 60000)
|
||||
]);
|
||||
|
||||
return { success: true, message: `Đã gửi mã OTP tới email ${email} thành công.` };
|
||||
} catch (error) {
|
||||
console.error('[Admin OTP] SMTP Error:', error);
|
||||
throw new BadRequestException('Lỗi cấu hình SMTP hoặc không thể kết nối tới máy chủ gửi mail');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('verify')
|
||||
async verifyOtp(@Body() body: { email: string; otp: string }) {
|
||||
const { email, otp } = body;
|
||||
const otpCacheKey = `otp:${email}`;
|
||||
const failCountKey = `otp_fails:${email}`;
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
// 1. Kiểm tra tài khoản có đang bị khóa không
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) throw new NotFoundException('Người dùng không tồn tại');
|
||||
if (user.isBlocked) {
|
||||
throw new ForbiddenException('Tài khoản này hiện đang bị khóa. Vui lòng liên hệ quản trị viên.');
|
||||
}
|
||||
|
||||
// 2. Lấy OTP từ Cache
|
||||
const storedOtp = await this.cacheManager.get<string>(otpCacheKey);
|
||||
if (!storedOtp) {
|
||||
throw new BadRequestException('Mã OTP đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu mã mới.');
|
||||
}
|
||||
|
||||
// 3. So sánh mã OTP
|
||||
if (storedOtp === otp) {
|
||||
// Thành công: Xóa OTP và bộ đếm lỗi
|
||||
await Promise.all([
|
||||
this.cacheManager.del(otpCacheKey),
|
||||
this.cacheManager.del(failCountKey)
|
||||
]);
|
||||
return { success: true, message: 'Xác thực mã OTP thành công.' };
|
||||
} else {
|
||||
// Thất bại: Tăng bộ đếm lỗi
|
||||
let fails: number = (await this.cacheManager.get<number>(failCountKey)) || 0;
|
||||
fails++;
|
||||
|
||||
if (fails >= MAX_FAILED_ATTEMPTS) {
|
||||
// Khóa tài khoản trong DB
|
||||
await this.prisma.user.update({
|
||||
where: { email },
|
||||
data: { isBlocked: true }
|
||||
});
|
||||
await this.cacheManager.del(failCountKey);
|
||||
await this.cacheManager.del(otpCacheKey);
|
||||
|
||||
throw new ForbiddenException(`Bạn đã nhập sai quá ${MAX_FAILED_ATTEMPTS} lần. Tài khoản đã bị khóa để bảo mật.`);
|
||||
} else {
|
||||
// Cập nhật số lần sai vào cache (TTL 5 phút bằng với OTP)
|
||||
await this.cacheManager.set(failCountKey, fails, 300000);
|
||||
throw new BadRequestException({
|
||||
message: `Mã OTP không chính xác. Bạn còn ${MAX_FAILED_ATTEMPTS - fails} lần thử.`,
|
||||
remainingAttempts: MAX_FAILED_ATTEMPTS - fails
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // Giúp ConfigModule có sẵn ở mọi nơi trong ứng dụng
|
||||
// Chỉ định đường dẫn tới file .env ở thư mục gốc của dự án
|
||||
envFilePath: path.resolve(__dirname, '..', '..', '.env'),
|
||||
// Bỏ qua lỗi nếu không tìm thấy file .env (hữu ích cho môi trường production dùng biến hệ thống)
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'production',
|
||||
}),
|
||||
CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
useFactory: async () => ({
|
||||
@@ -1496,14 +1716,17 @@ class CommentController {
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}) as any,
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
class AppModule {}
|
||||
|
||||
|
||||
bootstrap().catch(err => {
|
||||
if (err.message.includes('DATABASE_URL')) {
|
||||
console.error('❌ Lỗi nghiêm trọng: Biến môi trường DATABASE_URL không được tải. Hãy chắc chắn rằng file .env tồn tại ở thư mục gốc của dự án và chứa giá trị này.');
|
||||
}
|
||||
console.error('💥 Lỗi khởi động Server:');
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { LandingPage } from './pages/LandingPage';
|
||||
import { ExploreMap } from './pages/ExploreMap';
|
||||
import { TourDetailPage } from './pages/TourDetailPage';
|
||||
import { SignupPage } from './pages/SignupPage';
|
||||
import SignupPage from './pages/SignupPage';
|
||||
import { MyPhotosPage } from './pages/MyPhotosPage';
|
||||
import { MyNotePage } from './pages/MyNotePage';
|
||||
import { useTourStore } from './store/useTourStore';
|
||||
|
||||
+179
-144
@@ -1,12 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin } from 'lucide-react';
|
||||
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin, ShieldCheck } from 'lucide-react';
|
||||
|
||||
interface SignupPageProps {
|
||||
onBack: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
@@ -15,39 +15,58 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
||||
phone: '',
|
||||
address: ''
|
||||
});
|
||||
const [step, setStep] = useState<'form' | 'otp'>('form');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Mật khẩu xác nhận không khớp');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/auth/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
name: formData.name,
|
||||
phone: formData.phone || undefined,
|
||||
address: formData.address || undefined
|
||||
}),
|
||||
});
|
||||
if (step === 'form') {
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Mật khẩu xác nhận không khớp');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Đăng ký thất bại');
|
||||
const response = await fetch(`/api/v1/auth/signup/request`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
name: formData.name,
|
||||
phone: formData.phone || undefined,
|
||||
address: formData.address || undefined
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Yêu cầu đăng ký thất bại');
|
||||
|
||||
setStep('otp');
|
||||
} else {
|
||||
// Xác thực mã OTP bước hoàn tất
|
||||
const response = await fetch(`/api/v1/auth/signup/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
otp: otp
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Mã OTP không chính xác');
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -56,139 +75,155 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden font-sans bg-white">
|
||||
{/* Nửa bên trái: Hình ảnh decor (Đồng bộ với LandingPage) */}
|
||||
<div className="hidden lg:block lg:w-1/2 relative bg-blue-900">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||
alt="Travel background"
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-50"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/40 to-transparent" />
|
||||
<div className="absolute top-12 left-12 flex items-center gap-2 text-white z-10">
|
||||
<Compass className="w-8 h-8" />
|
||||
<span className="text-2xl font-black uppercase tracking-tighter">Travel Planner</span>
|
||||
</div>
|
||||
<div className="absolute bottom-20 left-12 text-white z-10 max-w-md">
|
||||
<h2 className="text-4xl font-bold mb-4">Bắt đầu hành trình của riêng bạn.</h2>
|
||||
<p className="text-blue-100 opacity-80">Tạo tài khoản để lưu lại những kế hoạch du lịch tuyệt vời nhất cùng bạn bè.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-white p-10 rounded-3xl shadow-2xl">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-6 left-6 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Nửa bên phải: Form đăng ký */}
|
||||
<div className="flex-1 flex flex-col justify-center px-8 sm:px-16 lg:px-24 py-12 overflow-y-auto">
|
||||
<div className="max-w-md w-full mx-auto">
|
||||
<button onClick={onBack} className="flex items-center text-gray-400 hover:text-blue-600 mb-8 transition-colors font-medium">
|
||||
<ChevronLeft className="w-5 h-5" /> Quay lại
|
||||
</button>
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">
|
||||
{step === 'form' ? 'Tạo tài khoản mới' : 'Xác thực tài khoản'}
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-2 font-medium">
|
||||
{step === 'form'
|
||||
? 'Khám phá các tính năng lập kế hoạch chuyên nghiệp.'
|
||||
: `Vui lòng nhập mã OTP đã được gửi tới ${formData.email}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">Tạo tài khoản mới</h1>
|
||||
<p className="text-gray-500 mt-2 font-medium">Khám phá các tính năng lập kế hoạch chuyên nghiệp.</p>
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
{step === 'form' ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Họ và tên</label>
|
||||
<div className="relative group">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Nhập họ và tên của bạn"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Họ và tên</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Email</label>
|
||||
<div className="relative group">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
placeholder="Nhập email của bạn"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Nhập mật khẩu"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận mật khẩu</label>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Xác nhận mật khẩu"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
|
||||
<div className="relative group">
|
||||
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Nhập số điện thoại"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Địa chỉ</label>
|
||||
<div className="relative group">
|
||||
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nhập địa chỉ"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Mã xác thực OTP</label>
|
||||
<div className="relative group">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<ShieldCheck className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Nguyễn Văn A"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
maxLength={6}
|
||||
placeholder="Nhập 6 số mã OTP"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl text-center font-bold tracking-[0.5em] text-lg focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Email</label>
|
||||
<div className="relative group">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
|
||||
<div className="relative group">
|
||||
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="0912 345 678"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Địa chỉ</label>
|
||||
<div className="relative group">
|
||||
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Quận 1, TP.HCM"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 rounded-2xl shadow-xl shadow-blue-100 transition-all active:scale-[0.98] disabled:opacity-50 mt-4"
|
||||
>
|
||||
{isLoading ? 'Đang xử lý...' : 'Tạo tài khoản'}
|
||||
{!isLoading && <ArrowRight className="w-5 h-5" />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<button
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 rounded-2xl shadow-xl shadow-blue-100 transition-all active:scale-[0.98] disabled:opacity-50 mt-4"
|
||||
>
|
||||
{isLoading ? 'Đang xử lý...' : step === 'form' ? 'Tạo tài khoản' : 'Xác nhận kích hoạt'}
|
||||
{!isLoading && <ArrowRight className="w-5 h-5" />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default SignupPage;
|
||||
|
||||
@@ -178,6 +178,10 @@ const MapRotationHandler = ({ rotation }: { rotation: number }) => {
|
||||
useEffect(() => {
|
||||
const container = map.getContainer();
|
||||
|
||||
// Đảm bảo tâm xoay luôn ở giữa và kích hoạt tăng tốc phần cứng để giảm lag trên iOS
|
||||
container.style.transformOrigin = 'center center';
|
||||
container.style.willChange = 'transform';
|
||||
|
||||
if (rotation === 0) {
|
||||
cumulativeRotationRef.current = 0;
|
||||
prevRotationRef.current = 0;
|
||||
@@ -193,9 +197,11 @@ const MapRotationHandler = ({ rotation }: { rotation: number }) => {
|
||||
cumulativeRotationRef.current += delta;
|
||||
prevRotationRef.current = rotation;
|
||||
|
||||
// Áp dụng transform với giá trị cộng dồn liên tục
|
||||
container.style.transform = `rotate(${cumulativeRotationRef.current}deg) scale(2.5)`;
|
||||
container.style.transition = 'transform 0.1s linear';
|
||||
// Áp dụng transform với giá trị cộng dồn liên tục.
|
||||
// 1. Dùng translateZ(0) để kích hoạt GPU trên iOS.
|
||||
// 2. Giảm scale xuống ~1.6 (vừa đủ che góc) để giảm tải cho bộ nhớ đệm đồ họa.
|
||||
container.style.transform = `rotate(${cumulativeRotationRef.current}deg) scale(2.2) translateZ(0)`;
|
||||
container.style.transition = 'transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1)';
|
||||
}, [rotation, map]);
|
||||
return null;
|
||||
};
|
||||
@@ -350,6 +356,21 @@ export const TourDetailPage = ({
|
||||
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
||||
|
||||
// State cho vị trí và hướng của người dùng
|
||||
|
||||
|
||||
|
||||
const [deviceOrientationHeading, setDeviceOrientationHeading] = useState<number | null>(null); // Hướng thiết bị (la bàn)
|
||||
|
||||
// Xác định hướng hiển thị của người dùng (ưu tiên hướng di chuyển từ GPS, sau đó đến la bàn thiết bị)
|
||||
const currentHeading = useMemo(() => {
|
||||
if (gpsHeading !== null && userSpeed !== null && userSpeed > 0.5) {
|
||||
return gpsHeading;
|
||||
} else if (deviceOrientationHeading !== null) {
|
||||
return deviceOrientationHeading;
|
||||
}
|
||||
return 0;
|
||||
}, [gpsHeading, userSpeed, deviceOrientationHeading]);
|
||||
|
||||
// Định nghĩa Icons bên trong Component bằng useMemo để đảm bảo tính ổn định và tránh lỗi render
|
||||
const mapIcons = useMemo(() => ({
|
||||
@@ -374,15 +395,23 @@ export const TourDetailPage = ({
|
||||
user: L.divIcon({
|
||||
className: '!bg-transparent !border-none',
|
||||
html: `
|
||||
<div class="relative">
|
||||
<div class="w-4 h-4 bg-blue-500 rounded-full border-2 border-white shadow-lg z-10"></div>
|
||||
<div class="absolute -inset-2 bg-blue-400 rounded-full opacity-40 animate-ping"></div>
|
||||
<div class="relative flex items-center justify-center">
|
||||
<!-- Hiệu ứng ping tỏa lan -->
|
||||
<div class="absolute w-8 h-8 bg-blue-400 rounded-full opacity-30 animate-ping"></div>
|
||||
|
||||
<!-- Marker chính màu xanh -->
|
||||
<div class="w-5 h-5 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center z-10">
|
||||
<!-- Mũi tên chỉ hướng nhìn (xoay theo heading) -->
|
||||
<div style="transform: rotate(${currentHeading}deg); transition: transform 0.2s ease-out;" class="absolute inset-0 flex flex-col items-center">
|
||||
<div class="w-0 h-0 border-l-[5px] border-l-transparent border-r-[5px] border-r-transparent border-b-[8px] border-b-white mt-[1px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8]
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
})
|
||||
}), []);
|
||||
}), [currentHeading]);
|
||||
|
||||
useEffect(() => {
|
||||
// Theo dõi vị trí GPS của người dùng (bao gồm hướng và tốc độ khi di chuyển)
|
||||
@@ -404,7 +433,7 @@ export const TourDetailPage = ({
|
||||
}, [isPublicView]); // Chỉ phụ thuộc vào isPublicView
|
||||
|
||||
// State mới cho hướng thiết bị (la bàn)
|
||||
const [deviceOrientationHeading, setDeviceOrientationHeading] = useState<number | null>(null);
|
||||
|
||||
|
||||
// Theo dõi hướng thiết bị (la bàn)
|
||||
useEffect(() => {
|
||||
|
||||
Generated
+65
@@ -25,6 +25,7 @@
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^3.1.3",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
@@ -37,6 +38,7 @@
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"nodemailer": "^9.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.12.0",
|
||||
@@ -2074,6 +2076,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz",
|
||||
"integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dotenv": "17.4.1",
|
||||
"dotenv-expand": "12.0.3",
|
||||
"lodash": "4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"rxjs": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config/node_modules/dotenv": {
|
||||
"version": "17.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
|
||||
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config/node_modules/dotenv-expand": {
|
||||
"version": "12.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
|
||||
"integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config/node_modules/dotenv-expand/node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/core": {
|
||||
"version": "11.1.27",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz",
|
||||
@@ -6471,6 +6527,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
Reference in New Issue
Block a user