diff --git a/.antigravityignore b/.antigravityignore new file mode 100644 index 0000000..3a483d4 --- /dev/null +++ b/.antigravityignore @@ -0,0 +1,13 @@ +node_modules/ +server/node_modules/ + +# Bỏ qua cấu hình hệ thống và Git +.git/ +.idea/ +.vscode/ + +# Bỏ qua các file log và build +*.log +dist/ +build/ +out/ \ No newline at end of file diff --git a/backend/prisma/migrations/20260622012027_add_photo_flagging/migration.sql b/backend/prisma/migrations/20260622012027_add_photo_flagging/migration.sql new file mode 100644 index 0000000..7d21a22 --- /dev/null +++ b/backend/prisma/migrations/20260622012027_add_photo_flagging/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Photo" ADD COLUMN "flaggedAt" TIMESTAMP(3), +ADD COLUMN "flaggedReason" TEXT, +ADD COLUMN "isFlagged" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d730a24..9996756 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -209,6 +209,9 @@ model Photo { capturedAt DateTime @default(now()) metadata Json? privacy PrivacyLevel @default(TOUR_ONLY) + isFlagged Boolean @default(false) + flaggedReason String? + flaggedAt DateTime? tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) diff --git a/backend/src/auth/jwt-auth.guard.ts b/backend/src/auth/jwt-auth.guard.ts index 18588a5..51a6517 100644 --- a/backend/src/auth/jwt-auth.guard.ts +++ b/backend/src/auth/jwt-auth.guard.ts @@ -1,5 +1,23 @@ import { Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { UnauthorizedException } from '@nestjs/common'; @Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') {} \ No newline at end of file +export class JwtAuthGuard extends AuthGuard('jwt') {} + +// Guard that rejects anonymous/guest users - used for dashboard and sensitive endpoints +@Injectable() +export class JwtAuthGuardNoAnonymous extends AuthGuard('jwt') { + handleRequest(err: any, user: any, info: any) { + if (err || !user) { + throw err || new UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn'); + } + + // Reject anonymous/temporary users + if (user.isAnonymous) { + throw new UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.'); + } + + return user; + } +} \ No newline at end of file diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index b5ea96a..e5b2864 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -22,6 +22,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) { throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn'); } + // Note: We allow anonymous users to pass JWT validation + // Individual endpoints decide whether to accept anonymous users based on their guard return user; } } \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts index ad585fc..d7c3781 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -23,6 +23,7 @@ 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 { JwtAuthGuardNoAnonymous } from './auth/jwt-auth.guard'; import { JwtStrategy } from './auth/jwt.strategy'; import { Reflector } from '@nestjs/core'; import { SetMetadata, CanActivate, ExecutionContext } from '@nestjs/common'; @@ -787,6 +788,37 @@ class TourController { } } }); + + // Auto-create default note template for new tour + const noteContent = `

${filteredTitle} - Initial Planning

+

Start Date: ${startDate ? new Date(startDate).toLocaleDateString() : 'TBD'}

+

End Date: ${endDate ? new Date(endDate).toLocaleDateString() : 'TBD'}

+

Adult Participants: ${adultCount || 1}

+

Child Participants: ${childCount || 0}

+

Key Items to Plan:

+ +

Add your planning notes here...

`; + + try { + await this.prisma.tourNote.create({ + data: { + tourId: tour.id, + userId: req.user.id, + title: `[${filteredTitle}] - Initial Planning`, + content: noteContent + } + }); + } catch (err) { + console.warn(`Failed to auto-create note for tour ${tour.id}:`, err.message); + } + await this.cacheManager.del(`/api/v1/tours/explore`); return tour; } @@ -1117,7 +1149,7 @@ class TourController { } @UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuardNoAnonymous) @Get('explore') async getPublicTours(@Req() req: any) { // Trả về tất cả các tour trong hệ thống để hiển thị trên bản đồ cộng đồng @@ -1596,18 +1628,42 @@ class TourController { } } + // Ensure /uploads/tours/ directory exists + const uploadsDir = path.dirname(displayFilePath); + if (!fs.existsSync(uploadsDir)) { + try { + fs.mkdirSync(uploadsDir, { recursive: true }); + console.log(`[Photo] Created directory: ${uploadsDir}`); + } catch (e) { + console.error(`[Photo] Failed to create directory ${uploadsDir}:`, e); + throw new BadRequestException(`Lỗi tạo thư mục: ${e.message}`); + } + } + // Sử dụng Sharp để resize và tối ưu dung lượng ảnh - await sharp(processBuffer) - .rotate() - .resize(2560, 2560, { - fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K - withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh - }) - .jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng - .toFile(displayFilePath); + try { + await sharp(processBuffer) + .rotate() + .resize(2560, 2560, { + fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K + withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh + }) + .jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng + .toFile(displayFilePath); + console.log(`[Photo] Display image saved successfully: ${displayFilePath}`); + } catch (e) { + console.error(`[Photo] Failed to save display image to ${displayFilePath}:`, e); + throw new BadRequestException(`Lỗi lưu hình ảnh: ${e.message}`); + } + + // Verify file was created + if (!fs.existsSync(displayFilePath)) { + console.error(`[Photo] File verification failed at ${displayFilePath}`); + throw new BadRequestException('Lỗi: Hình ảnh không được lưu thành công. Vui lòng kiểm tra quyền lưu trữ.'); + } // 6. Lưu thông tin vào Database (Lưu cả 2 đường dẫn và metadata GPS) - return this.prisma.photo.create({ + const photoRecord = await this.prisma.photo.create({ data: { tourId: tourId, uploaderId: uploaderId, @@ -1620,6 +1676,8 @@ class TourController { } } }); + console.log(`[Photo] Database record created: ID=${photoRecord.id}, imageUrl=${photoRecord.imageUrl}`); + return photoRecord; })); } @@ -1680,20 +1738,39 @@ class TourController { @Post('join-by-token') async joinByToken(@Body() body: { token: string }, @Req() req: any) { const { token } = body; + console.log('[joinByToken] Request received, token length:', token ? token.length : 0); + if (!token) { + console.error('[joinByToken] No token provided'); throw new BadRequestException('Vui lòng cung cấp token lời mời.'); } + console.log('[joinByToken] Searching for invitation with token:', token.substring(0, 20) + '...'); const invitation = await this.prisma.tourInvitation.findUnique({ where: { token }, - include: { tour: { select: { title: true } } } + include: { tour: { select: { id: true, title: true } } } }); if (!invitation) { + console.error('[joinByToken] Invitation not found for token:', token.substring(0, 20) + '...'); + // Check how many invitations exist in database for debugging + const totalInvitations = await this.prisma.tourInvitation.count(); + console.log('[joinByToken] Total invitations in database:', totalInvitations); throw new NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.'); } + // Verify the invitation email matches the logged-in user's email + const userEmail = req.user.email; + console.log('[joinByToken] Checking email - User:', userEmail, 'Invitation:', invitation.email); + if (invitation.email && userEmail && invitation.email !== userEmail) { + console.error('[joinByToken] Email mismatch - User email:', userEmail, 'Invitation email:', invitation.email); + throw new BadRequestException('Lời mời này được gửi tới một địa chỉ email khác. Vui lòng đăng nhập bằng tài khoản đúng.'); + } + + console.log('[joinByToken] Invitation found, tourId:', invitation.tourId, 'expired:', invitation.expiredAt < new Date()); + if (invitation.expiredAt < new Date()) { + console.error('[joinByToken] Invitation expired at:', invitation.expiredAt); try { await this.prisma.tourInvitation.delete({ where: { id: invitation.id } }); } catch (e) {} @@ -1701,12 +1778,14 @@ class TourController { } const userId = req.user.id; + console.log('[joinByToken] User attempting to join - userId:', userId, 'tourId:', invitation.tourId); const existingParticipation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId: invitation.tourId, userId } } }); if (existingParticipation) { + console.log('[joinByToken] User is already a participant'); try { await this.prisma.tourInvitation.delete({ where: { id: invitation.id } }); } catch (e) {} @@ -1743,6 +1822,7 @@ class TourController { }) ]); + console.log('[joinByToken] Successfully joined tour:', invitation.tourId, 'userId:', userId); return { success: true, tourId: invitation.tourId, message: `Bạn đã gia nhập hành trình "${invitation.tour.title}"!`, shouldRefresh: true }; } @@ -2112,13 +2192,17 @@ class PhotoController { const displayFilename = `${uniqueSuffix}.jpg`; const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals'); - const displayFilePath = path.join(UPLOAD_ROOT, 'tours', displayFilename); + const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours'); + const displayFilePath = path.join(tourDisplayPath, displayFilename); const originalFilePath = path.join(memberOriginalDir, originalFilename); - // Đảm bảo thư mục lưu trữ ảnh gốc tồn tại + // Đảm bảo thư mục lưu trữ ảnh gốc và ảnh hiển thị tồn tại if (!fs.existsSync(memberOriginalDir)) { fs.mkdirSync(memberOriginalDir, { recursive: true }); } + if (!fs.existsSync(tourDisplayPath)) { + fs.mkdirSync(tourDisplayPath, { recursive: true }); + } // 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của khách await fs.promises.writeFile(originalFilePath, file.buffer); @@ -3014,6 +3098,33 @@ class PublicPhotoController { res.type('text/html').send(htmlContent); } + + @UseGuards(JwtAuthGuard) + @Post(':photoId/flag') + async flagPhoto( + @Param('photoId', ParseUUIDPipe) photoId: string, + @Body() body: { reason: string }, + @Req() req: any + ) { + const photo = await this.prisma.photo.findUnique({ + where: { id: photoId } + }); + if (!photo) { + throw new NotFoundException('Ảnh không tồn tại'); + } + + // Update photo as flagged + const updatedPhoto = await this.prisma.photo.update({ + where: { id: photoId }, + data: { + isFlagged: true, + flaggedReason: body.reason || 'Không xác định', + flaggedAt: new Date() + } + }); + + return { success: true, message: 'Đã báo cáo ảnh thành công', photo: updatedPhoto }; + } } @UseGuards(JwtAuthGuard) @@ -3536,6 +3647,67 @@ class AdminModerationController { } } +@Controller('admin/photos') +@UseGuards(JwtAuthGuard, AdminGuard) +class AdminPhotosController { + constructor(private prisma: PrismaService) {} + + @Get('flagged') + async getFlaggedPhotos() { + return this.prisma.photo.findMany({ + where: { isFlagged: true }, + include: { + uploader: { select: { id: true, name: true, email: true } }, + tour: { select: { id: true, title: true } } + }, + orderBy: { flaggedAt: 'desc' } + }); + } + + @Post(':photoId/approve') + async approvePhoto(@Param('photoId', ParseUUIDPipe) photoId: string) { + const photo = await this.prisma.photo.findUnique({ + where: { id: photoId } + }); + if (!photo) { + throw new NotFoundException('Ảnh không tồn tại'); + } + + // Unflag the photo + const updatedPhoto = await this.prisma.photo.update({ + where: { id: photoId }, + data: { + isFlagged: false, + flaggedReason: null, + flaggedAt: null + } + }); + + return { success: true, message: 'Đã duyệt ảnh thành công', photo: updatedPhoto }; + } + + @Delete(':photoId') + async deletePhoto(@Param('photoId', ParseUUIDPipe) photoId: string) { + const photo = await this.prisma.photo.findUnique({ + where: { id: photoId } + }); + if (!photo) { + throw new NotFoundException('Ảnh không tồn tại'); + } + + // Soft delete the photo + const deletedPhoto = await this.prisma.photo.update({ + where: { id: photoId }, + data: { + isDeleted: true, + deletedAt: new Date() + } + }); + + return { success: true, message: 'Đã xóa ảnh thành công', photo: deletedPhoto }; + } +} + @Controller('reports') class ReportsController { constructor(private prisma: PrismaService) {} @@ -4141,54 +4313,105 @@ class AdminTrashController { @Post('delete-permanent') async deletePermanentItems(@Body() body: { type: 'tour' | 'photo' | 'note'; ids: string[] }) { const { type, ids } = body; + console.log('[deletePermanent] Request received - type:', type, 'ids count:', ids.length); + if (!type || !ids || !Array.isArray(ids)) { throw new BadRequestException('Tham số không hợp lệ.'); } if (type === 'tour') { for (const tourId of ids) { + console.log('[deletePermanent] Deleting tour:', tourId); const photos = await this.prisma.photo.findMany({ where: { tourId } }); + console.log('[deletePermanent] Found', photos.length, 'photos for tour'); + for (const p of photos) { if (p.imageUrl) { const displayFilePath = path.join(process.cwd(), p.imageUrl.replace(/^\//, '')); + console.log('[deletePermanent] Checking display file:', displayFilePath); if (fs.existsSync(displayFilePath)) { - try { fs.unlinkSync(displayFilePath); } catch (e) {} + try { + fs.unlinkSync(displayFilePath); + console.log('[deletePermanent] Deleted display file:', displayFilePath); + } catch (e) { + console.error('[deletePermanent] Error deleting display file:', e); + } + } else { + console.log('[deletePermanent] Display file not found (OK):', displayFilePath); } } if (p.originalUrl) { const originalFilePath = path.join(process.cwd(), p.originalUrl.replace(/^\//, '')); + console.log('[deletePermanent] Checking original file:', originalFilePath); if (fs.existsSync(originalFilePath)) { - try { fs.unlinkSync(originalFilePath); } catch (e) {} + try { + fs.unlinkSync(originalFilePath); + console.log('[deletePermanent] Deleted original file:', originalFilePath); + } catch (e) { + console.error('[deletePermanent] Error deleting original file:', e); + } + } else { + console.log('[deletePermanent] Original file not found (OK):', originalFilePath); } } } await this.prisma.tour.delete({ where: { id: tourId } }); + console.log('[deletePermanent] Deleted tour from database:', tourId); } } else if (type === 'photo') { + console.log('[deletePermanent] Deleting', ids.length, 'photos'); for (const photoId of ids) { + console.log('[deletePermanent] Processing photo:', photoId); const photo = await this.prisma.photo.findUnique({ where: { id: photoId } }); - if (photo) { - if (photo.imageUrl) { - const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, '')); - if (fs.existsSync(displayFilePath)) { - try { fs.unlinkSync(displayFilePath); } catch (e) {} - } - } - if (photo.originalUrl) { - const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, '')); - if (fs.existsSync(originalFilePath)) { - try { fs.unlinkSync(originalFilePath); } catch (e) {} - } - } - await this.prisma.photo.delete({ where: { id: photoId } }); + if (!photo) { + console.warn('[deletePermanent] Photo not found:', photoId); + continue; } + + console.log('[deletePermanent] Photo found, imageUrl:', photo.imageUrl); + + if (photo.imageUrl) { + const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, '')); + console.log('[deletePermanent] Checking display file:', displayFilePath); + if (fs.existsSync(displayFilePath)) { + try { + fs.unlinkSync(displayFilePath); + console.log('[deletePermanent] Deleted display file'); + } catch (e) { + console.error('[deletePermanent] Error deleting display file:', e); + } + } else { + console.log('[deletePermanent] Display file not found (OK)'); + } + } + + if (photo.originalUrl) { + const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, '')); + console.log('[deletePermanent] Checking original file:', originalFilePath); + if (fs.existsSync(originalFilePath)) { + try { + fs.unlinkSync(originalFilePath); + console.log('[deletePermanent] Deleted original file'); + } catch (e) { + console.error('[deletePermanent] Error deleting original file:', e); + } + } else { + console.log('[deletePermanent] Original file not found (OK)'); + } + } + + const result = await this.prisma.photo.delete({ where: { id: photoId } }); + console.log('[deletePermanent] Deleted photo from database:', photoId); } } else if (type === 'note') { - await this.prisma.tourNote.deleteMany({ + console.log('[deletePermanent] Deleting', ids.length, 'notes'); + const result = await this.prisma.tourNote.deleteMany({ where: { id: { in: ids } } }); + console.log('[deletePermanent] Deleted notes from database, count:', result.count); } + console.log('[deletePermanent] Deletion complete, type:', type); return { success: true }; } } diff --git a/backend/uploads/members/43265bbe-797d-424f-862c-eb61e9c2fe01/originals/1782099792835-465444865.jpg b/backend/uploads/members/43265bbe-797d-424f-862c-eb61e9c2fe01/originals/1782099792835-465444865.jpg new file mode 100644 index 0000000..d308ce5 Binary files /dev/null and b/backend/uploads/members/43265bbe-797d-424f-862c-eb61e9c2fe01/originals/1782099792835-465444865.jpg differ diff --git a/backend/uploads/members/43265bbe-797d-424f-862c-eb61e9c2fe01/originals/1782100326127-171966975.jpg b/backend/uploads/members/43265bbe-797d-424f-862c-eb61e9c2fe01/originals/1782100326127-171966975.jpg new file mode 100644 index 0000000..6e2966e Binary files /dev/null and b/backend/uploads/members/43265bbe-797d-424f-862c-eb61e9c2fe01/originals/1782100326127-171966975.jpg differ diff --git a/backend/uploads/members/43265bbe-797d-424f-862c-eb61e9c2fe01/originals/1782100590684-581575174.jpg b/backend/uploads/members/43265bbe-797d-424f-862c-eb61e9c2fe01/originals/1782100590684-581575174.jpg new file mode 100644 index 0000000..8dd15c6 Binary files /dev/null and b/backend/uploads/members/43265bbe-797d-424f-862c-eb61e9c2fe01/originals/1782100590684-581575174.jpg differ diff --git a/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782097958694-34402464.jpg b/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782097958694-34402464.jpg new file mode 100644 index 0000000..202c0ab Binary files /dev/null and b/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782097958694-34402464.jpg differ diff --git a/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782098955193-504499361.jpg b/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782098955193-504499361.jpg new file mode 100644 index 0000000..731ff0d Binary files /dev/null and b/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782098955193-504499361.jpg differ diff --git a/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782099003772-651088143.jpg b/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782099003772-651088143.jpg new file mode 100644 index 0000000..e02da56 Binary files /dev/null and b/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782099003772-651088143.jpg differ diff --git a/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782099284022-598478079.jpg b/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782099284022-598478079.jpg new file mode 100644 index 0000000..930dcc8 Binary files /dev/null and b/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782099284022-598478079.jpg differ diff --git a/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782099558534-819985170.jpg b/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782099558534-819985170.jpg new file mode 100644 index 0000000..12d13e8 Binary files /dev/null and b/backend/uploads/members/b782b58c-28ed-4725-8110-2e5914be42ef/originals/1782099558534-819985170.jpg differ diff --git a/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782096253531-758980589.jpg b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782096253531-758980589.jpg new file mode 100644 index 0000000..5b9e042 Binary files /dev/null and b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782096253531-758980589.jpg differ diff --git a/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782097908001-770537095.jpg b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782097908001-770537095.jpg new file mode 100644 index 0000000..2785cd9 Binary files /dev/null and b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782097908001-770537095.jpg differ diff --git a/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782098598511-366218057.jpg b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782098598511-366218057.jpg new file mode 100644 index 0000000..6b37b09 Binary files /dev/null and b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782098598511-366218057.jpg differ diff --git a/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782099024955-859396932.jpg b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782099024955-859396932.jpg new file mode 100644 index 0000000..d909115 Binary files /dev/null and b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782099024955-859396932.jpg differ diff --git a/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782099305251-634989764.jpg b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782099305251-634989764.jpg new file mode 100644 index 0000000..d8ccaf4 Binary files /dev/null and b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782099305251-634989764.jpg differ diff --git a/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782099673822-87952177.jpg b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782099673822-87952177.jpg new file mode 100644 index 0000000..14c7a3d Binary files /dev/null and b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782099673822-87952177.jpg differ diff --git a/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782100420433-736686613.jpg b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782100420433-736686613.jpg new file mode 100644 index 0000000..4a888f8 Binary files /dev/null and b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782100420433-736686613.jpg differ diff --git a/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782101005582-952029411.jpg b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782101005582-952029411.jpg new file mode 100644 index 0000000..6fdf28e Binary files /dev/null and b/backend/uploads/members/c6828597-682c-4676-8375-523a274d3c7b/originals/1782101005582-952029411.jpg differ diff --git a/backend/uploads/members/cdac7a4e-fd1b-4765-adae-e1273871f461/originals/1782100955639-135095862.jpg b/backend/uploads/members/cdac7a4e-fd1b-4765-adae-e1273871f461/originals/1782100955639-135095862.jpg new file mode 100644 index 0000000..2f3794b Binary files /dev/null and b/backend/uploads/members/cdac7a4e-fd1b-4765-adae-e1273871f461/originals/1782100955639-135095862.jpg differ diff --git a/backend/uploads/members/cdac7a4e-fd1b-4765-adae-e1273871f461/originals/1782101476154-635865207.jpg b/backend/uploads/members/cdac7a4e-fd1b-4765-adae-e1273871f461/originals/1782101476154-635865207.jpg new file mode 100644 index 0000000..175bf6e Binary files /dev/null and b/backend/uploads/members/cdac7a4e-fd1b-4765-adae-e1273871f461/originals/1782101476154-635865207.jpg differ diff --git a/backend/uploads/tours/1782096253531-758980589.jpg b/backend/uploads/tours/1782096253531-758980589.jpg new file mode 100644 index 0000000..b084436 Binary files /dev/null and b/backend/uploads/tours/1782096253531-758980589.jpg differ diff --git a/backend/uploads/tours/1782097908001-770537095.jpg b/backend/uploads/tours/1782097908001-770537095.jpg new file mode 100644 index 0000000..ffc2b9c Binary files /dev/null and b/backend/uploads/tours/1782097908001-770537095.jpg differ diff --git a/backend/uploads/tours/1782097958694-34402464.jpg b/backend/uploads/tours/1782097958694-34402464.jpg new file mode 100644 index 0000000..3bcf349 Binary files /dev/null and b/backend/uploads/tours/1782097958694-34402464.jpg differ diff --git a/backend/uploads/tours/1782098598511-366218057.jpg b/backend/uploads/tours/1782098598511-366218057.jpg new file mode 100644 index 0000000..dbe45b1 Binary files /dev/null and b/backend/uploads/tours/1782098598511-366218057.jpg differ diff --git a/backend/uploads/tours/1782098955193-504499361.jpg b/backend/uploads/tours/1782098955193-504499361.jpg new file mode 100644 index 0000000..a757dbf Binary files /dev/null and b/backend/uploads/tours/1782098955193-504499361.jpg differ diff --git a/backend/uploads/tours/1782099003772-651088143.jpg b/backend/uploads/tours/1782099003772-651088143.jpg new file mode 100644 index 0000000..b4e9975 Binary files /dev/null and b/backend/uploads/tours/1782099003772-651088143.jpg differ diff --git a/backend/uploads/tours/1782099024955-859396932.jpg b/backend/uploads/tours/1782099024955-859396932.jpg new file mode 100644 index 0000000..151bb18 Binary files /dev/null and b/backend/uploads/tours/1782099024955-859396932.jpg differ diff --git a/backend/uploads/tours/1782099284022-598478079.jpg b/backend/uploads/tours/1782099284022-598478079.jpg new file mode 100644 index 0000000..1ac3fb7 Binary files /dev/null and b/backend/uploads/tours/1782099284022-598478079.jpg differ diff --git a/backend/uploads/tours/1782099305251-634989764.jpg b/backend/uploads/tours/1782099305251-634989764.jpg new file mode 100644 index 0000000..e668e17 Binary files /dev/null and b/backend/uploads/tours/1782099305251-634989764.jpg differ diff --git a/backend/uploads/tours/1782099558534-819985170.jpg b/backend/uploads/tours/1782099558534-819985170.jpg new file mode 100644 index 0000000..c9212eb Binary files /dev/null and b/backend/uploads/tours/1782099558534-819985170.jpg differ diff --git a/backend/uploads/tours/1782099673822-87952177.jpg b/backend/uploads/tours/1782099673822-87952177.jpg new file mode 100644 index 0000000..bdae9ee Binary files /dev/null and b/backend/uploads/tours/1782099673822-87952177.jpg differ diff --git a/backend/uploads/tours/1782099792835-465444865.jpg b/backend/uploads/tours/1782099792835-465444865.jpg new file mode 100644 index 0000000..3f09313 Binary files /dev/null and b/backend/uploads/tours/1782099792835-465444865.jpg differ diff --git a/backend/uploads/tours/1782100326127-171966975.jpg b/backend/uploads/tours/1782100326127-171966975.jpg new file mode 100644 index 0000000..0837130 Binary files /dev/null and b/backend/uploads/tours/1782100326127-171966975.jpg differ diff --git a/backend/uploads/tours/1782100420433-736686613.jpg b/backend/uploads/tours/1782100420433-736686613.jpg new file mode 100644 index 0000000..3998813 Binary files /dev/null and b/backend/uploads/tours/1782100420433-736686613.jpg differ diff --git a/backend/uploads/tours/1782100590684-581575174.jpg b/backend/uploads/tours/1782100590684-581575174.jpg new file mode 100644 index 0000000..4cbeb28 Binary files /dev/null and b/backend/uploads/tours/1782100590684-581575174.jpg differ diff --git a/backend/uploads/tours/1782100955639-135095862.jpg b/backend/uploads/tours/1782100955639-135095862.jpg new file mode 100644 index 0000000..3651f65 Binary files /dev/null and b/backend/uploads/tours/1782100955639-135095862.jpg differ diff --git a/backend/uploads/tours/1782101005582-952029411.jpg b/backend/uploads/tours/1782101005582-952029411.jpg new file mode 100644 index 0000000..5799dbd Binary files /dev/null and b/backend/uploads/tours/1782101005582-952029411.jpg differ diff --git a/backend/uploads/tours/1782101476154-635865207.jpg b/backend/uploads/tours/1782101476154-635865207.jpg new file mode 100644 index 0000000..770a74c Binary files /dev/null and b/backend/uploads/tours/1782101476154-635865207.jpg differ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ac1a0f9..842cd4a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: postgres: image: postgres:15-alpine @@ -32,6 +30,8 @@ services: sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js" ports: - "3001:3001" + volumes: + - ./backend/uploads:/usr/src/app/uploads environment: DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public" REDIS_URL: "redis://redis:6379" diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 45d0002..0da7adc 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,6 +1,6 @@ server { listen 80; - server_name localhost; + server_name yotrip.labz.io.vn localhost; client_max_body_size 50M; location / { @@ -9,7 +9,7 @@ server { try_files $uri $uri/ /index.html; } - # Proxy requests to backend for deployment on same server/domain (optional but good practice) + # Proxy API requests to backend location /api/v1/ { proxy_pass http://backend:3001; proxy_http_version 1.1; @@ -19,6 +19,14 @@ server { proxy_cache_bypass $http_upgrade; } + # Serve uploaded files from backend + location /uploads/ { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + # Proxy WebSocket connection location /socket.io/ { proxy_pass http://backend:3001; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 347528a..b5795b9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { MyPhotosPage } from './pages/MyPhotosPage'; import { MyNotePage } from './pages/MyNotePage'; import { JoinTourPage } from './pages/JoinTourPage'; import { MemberDashboard } from './pages/MemberDashboard'; +import { AdminDashboard } from './pages/AdminDashboard'; import { ShareJourneyPage } from './pages/ShareJourneyPage'; import { ConfirmProvider } from './hooks/useConfirm'; import { NotificationProvider } from './hooks/useNotification'; @@ -20,7 +21,7 @@ function App() { const [user, setUser] = useState(null); const [shareJourneyToken, setShareJourneyToken] = useState(journeyTokenVal); - const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney'>( + const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'admin' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney'>( journeyTokenVal ? 'shareJourney' : (viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing')) ); const [currentTourId, setCurrentTourId] = useState(viewTourId); @@ -34,9 +35,20 @@ function App() { const pathParts = window.location.pathname.split('/'); const journeyToken = pathParts[1] === 'journey' && pathParts[2] ? pathParts[2] : null; + // Check if user just finished uploading from a public tour + const fromPublicUpload = localStorage.getItem('fromPublicUpload'); + if (fromPublicUpload) { + localStorage.removeItem('fromPublicUpload'); + setCurrentPage('landing'); + return; + } + // Khôi phục thông tin đăng nhập nếu có const token = localStorage.getItem('token'); + const guestToken = localStorage.getItem('guest_token'); const storedUser = localStorage.getItem('user'); + const storedGuestUser = localStorage.getItem('guest_user'); + let loggedInUser = null; if (token && storedUser) { try { @@ -56,8 +68,18 @@ function App() { } else if (viewTourId) { setCurrentPage('tourDetail'); } else { - if (loggedInUser) { - setCurrentPage('dashboard'); + // Check if only guest token exists (no real login) + const isGuestOnly = guestToken && !token; + if (isGuestOnly) { + // Guest user trying to access dashboard - redirect to landing + setCurrentPage('landing'); + } else if (loggedInUser) { + // Real authenticated user + if (loggedInUser.isAdmin) { + setCurrentPage('admin'); + } else { + setCurrentPage('dashboard'); + } } else { setCurrentPage('landing'); } @@ -71,8 +93,18 @@ function App() { const pendingInviteToken = localStorage.getItem('pendingInviteToken'); if (pendingInviteToken) { setCurrentPage('joinTour'); + } else if (loggedInUser.isAdmin) { + // Nếu là admin, chuyển đến admin dashboard + setCurrentPage('admin'); } else { - setCurrentPage('dashboard'); + // Only set to dashboard if this is a real user (has token), not a guest + const token = localStorage.getItem('token'); + const guestToken = localStorage.getItem('guest_token'); + if (token && !guestToken) { + setCurrentPage('dashboard'); + } else { + setCurrentPage('landing'); + } } }; @@ -91,12 +123,22 @@ function App() { }; const handleBackFromTourDetail = () => { + // Check if this was a public view BEFORE clearing the flag + const wasPublicView = isPublicTourView; + setCurrentTourId(null); setIsPublicTourView(false); - // Quay về trang cũ nếu đã đăng nhập, ngược lại quay về Landing - if (user) { + + // If user was viewing a public tour, redirect to index/landing page + // Otherwise redirect based on authentication and previous page + if (wasPublicView) { + // Public tour view - always redirect to index/landing + setCurrentPage('landing'); + } else if (user) { + // Authenticated user viewing their own tour - go back to previous page setCurrentPage(previousPage); } else { + // Not authenticated and not public view - go to landing setCurrentPage('landing'); } }; @@ -137,7 +179,26 @@ function App() { }; const handleBackFromExplore = () => { - if (user) { + // Only allow real users (with token, not guest_token) + const token = localStorage.getItem('token'); + const guestToken = localStorage.getItem('guest_token'); + const isRealUser = token && !guestToken; + + if (user && isRealUser) { + setCurrentPage('dashboard'); + } else { + setCurrentPage('landing'); + } + }; + + const handleGoToDashboard = () => { + // Only allow real users (with token, not guest_token) + const token = localStorage.getItem('token'); + const guestToken = localStorage.getItem('guest_token'); + const isRealUser = token && !guestToken; + + if (user && isRealUser) { + setPreviousPage('explore'); setCurrentPage('dashboard'); } else { setCurrentPage('landing'); @@ -147,14 +208,26 @@ function App() { const handleGoToHome = () => { window.history.pushState({}, '', '/'); setShareJourneyToken(null); - const loggedIn = !!localStorage.getItem('token'); - setCurrentPage(loggedIn ? 'dashboard' : 'landing'); + const token = localStorage.getItem('token'); + const guestToken = localStorage.getItem('guest_token'); + // Only real authenticated users can access dashboard, not guests + const isRealUser = token && !guestToken; + setCurrentPage(isRealUser ? 'dashboard' : 'landing'); }; return ( {(() => { + if (currentPage === 'admin') { + return ( + + ); + } + if (currentPage === 'dashboard') { return ( setCurrentPage('myPhotos')} onLoginSuccess={handleLoginSuccess} + onGoToDashboard={handleGoToDashboard} /> ); } diff --git a/frontend/src/components/AddPhotoModal.tsx b/frontend/src/components/AddPhotoModal.tsx index 51f9860..59604d4 100644 --- a/frontend/src/components/AddPhotoModal.tsx +++ b/frontend/src/components/AddPhotoModal.tsx @@ -10,9 +10,10 @@ interface AddPhotoModalProps { onClose: () => void; tourId: string; onSuccess?: () => void; + isPublicView?: boolean; } -export const AddPhotoModal: React.FC = ({ isOpen, onClose, tourId, onSuccess }) => { +export const AddPhotoModal: React.FC = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => { const [selectedFiles, setSelectedFiles] = useState([]); const [previews, setPreviews] = useState([]); const [isUploading, setIsUploading] = useState(false); @@ -136,13 +137,26 @@ export const AddPhotoModal: React.FC = ({ isOpen, onClose, t type: 'success' }); - fetchTour(tourId); - if (onSuccess) onSuccess(); - onClose(); // Giải phóng bộ nhớ sau khi hoàn tất previews.forEach(url => URL.revokeObjectURL(url)); setSelectedFiles([]); setPreviews([]); + + // For public users: redirect to landing page after upload + // For authenticated users: refresh tour data and close modal + if (isPublicView) { + onClose(); + // Give time for notification to show before redirecting + // Set flag so App.tsx knows to redirect to landing even if user is logged in + setTimeout(() => { + localStorage.setItem('fromPublicUpload', 'true'); + window.location.href = '/'; + }, 1000); + } else { + fetchTour(tourId); + if (onSuccess) onSuccess(); + onClose(); + } } catch (error) { notify({ title: 'Lỗi', diff --git a/frontend/src/components/JoinTourLoginModal.tsx b/frontend/src/components/JoinTourLoginModal.tsx new file mode 100644 index 0000000..89ff392 --- /dev/null +++ b/frontend/src/components/JoinTourLoginModal.tsx @@ -0,0 +1,296 @@ +import React, { useState, useEffect } from 'react'; +import { X, Mail, Lock, ArrowRight, Loader2, LogIn } from 'lucide-react'; +import { useNotification } from '@/hooks/useNotification'; + +interface JoinTourLoginModalProps { + isOpen: boolean; + onClose: () => void; + inviteToken: string; + onJoinSuccess?: (user: any, tourData: any) => void; + onSwitchToSignup?: () => void; +} + +export const JoinTourLoginModal: React.FC = ({ + isOpen, + onClose, + inviteToken, + onJoinSuccess, + onSwitchToSignup +}) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const notify = useNotification(); + + const handleGoogleLogin = async (googleResponse: any) => { + setError(''); + setIsLoading(true); + try { + const response = await fetch(`/api/v1/auth/google`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: googleResponse.credential }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Đăng nhập Google thất bại'); + } + + localStorage.setItem('token', data.access_token); + localStorage.setItem('user', JSON.stringify(data.user)); + + // Attempt to join tour with the token + try { + console.log('[JoinTourLogin] Attempting to join with token:', inviteToken.substring(0, 10) + '...'); + const joinRes = await fetch(`/api/v1/tours/join-by-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${data.access_token}`, + }, + body: JSON.stringify({ token: inviteToken }), + }); + + console.log('[JoinTourLogin] Join response status:', joinRes.status); + const joinData = await joinRes.json().catch(() => ({})); + + if (joinRes.ok) { + console.log('[JoinTourLogin] Join successful'); + notify({ + title: 'Thành công', + message: joinData.message || 'Bạn đã gia nhập tour!', + type: 'success' + }); + localStorage.removeItem('pendingInviteToken'); + if (onJoinSuccess) { + onJoinSuccess(data.user, joinData); + } + onClose(); + } else { + // Email mismatch or other error + const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`; + console.error('[JoinTourLogin] Join failed:', errorMessage); + setError(`Lỗi gia nhập tour: ${errorMessage}`); + setIsLoading(false); + } + } catch (e: any) { + console.error('[JoinTourLogin] Join exception:', e); + setError(`Lỗi khi gia nhập: ${e.message}`); + setIsLoading(false); + } + } catch (err: any) { + console.error('[JoinTourLogin] Google login error:', err); + setError(err.message); + setIsLoading(false); + } + }; + + const handleEmailPasswordJoin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + const response = await fetch(`/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'Đăng nhập thất bại'); + } + + localStorage.setItem('token', data.access_token); + localStorage.setItem('user', JSON.stringify(data.user)); + + // Attempt to join tour + try { + const joinRes = await fetch(`/api/v1/tours/join-by-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${data.access_token}`, + }, + body: JSON.stringify({ token: inviteToken }), + }); + + const joinData = await joinRes.json().catch(() => ({})); + + if (joinRes.ok) { + notify({ + title: 'Thành công', + message: joinData.message || 'Bạn đã gia nhập tour!', + type: 'success' + }); + localStorage.removeItem('pendingInviteToken'); + if (onJoinSuccess) { + onJoinSuccess(data.user, joinData); + } + onClose(); + } else { + const errorMessage = joinData.message || 'Không thể gia nhập tour'; + setError(`Lỗi gia nhập tour: ${errorMessage}`); + setIsLoading(false); + } + } catch (e: any) { + setError(`Lỗi khi gia nhập: ${e.message}`); + setIsLoading(false); + } + } catch (err: any) { + console.error('[JoinTourLogin] Login error:', err); + setError(err.message); + setIsLoading(false); + } + }; + + useEffect(() => { + if (!isOpen) return; + + const timer = setTimeout(() => { + if (typeof window !== 'undefined' && (window as any).google) { + try { + (window as any).google.accounts.id.initialize({ + client_id: (import.meta as any).env.VITE_GOOGLE_CLIENT_ID || '864264639911-dummyid.apps.googleusercontent.com', + callback: handleGoogleLogin, + }); + + (window as any).google.accounts.id.renderButton( + document.getElementById('google-signin-btn-join-tour'), + { theme: 'outline', size: 'large', width: '380' } + ); + } catch (e) { + console.error('Lỗi khởi tạo Google Sign-in:', e); + } + } + }, 100); + + return () => clearTimeout(timer); + }, [isOpen]); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+
+
+ +
+ +
+
+ + {/* Content */} +
+

+ Gia nhập tour +

+

+ Đăng nhập để tham gia chuyến du lịch này +

+ + {error && ( +
+ {error} +
+ )} + + {/* Google OAuth Button */} +
+
+
+ +
+
+
+
+
+ Hoặc +
+
+ + {/* Email/Password Form */} +
+
+ +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + required + className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + placeholder="Nhập mật khẩu" + required + className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" + /> +
+
+ + +
+ + {/* Signup Link */} +
+ Chưa có tài khoản?{' '} + +
+
+
+
+ ); +}; diff --git a/frontend/src/components/LoginModal.tsx b/frontend/src/components/LoginModal.tsx index 5104dbc..605fd8f 100644 --- a/frontend/src/components/LoginModal.tsx +++ b/frontend/src/components/LoginModal.tsx @@ -31,34 +31,16 @@ export const LoginModal: React.FC = ({ isOpen, onClose, onSwitc localStorage.setItem('token', data.access_token); localStorage.setItem('user', JSON.stringify(data.user)); + console.log('[OAuth] Google login successful'); - // Tự động gia nhập tour nếu có pendingInviteToken - const pendingInviteToken = localStorage.getItem('pendingInviteToken'); - if (pendingInviteToken) { - try { - const joinRes = await fetch(`/api/v1/tours/join-by-token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${data.access_token}`, - }, - body: JSON.stringify({ token: pendingInviteToken }), - }); - if (joinRes.ok) { - localStorage.removeItem('pendingInviteToken'); - } - } catch (e) { - console.error('Lỗi tự động gia nhập:', e); - } - } - + // Regular login - no auto-join for tour if (onLoginSuccess) { onLoginSuccess(data.user); } onClose(); } catch (err: any) { + console.error('[OAuth] Login error:', err); setError(err.message); - } finally { setIsLoading(false); } }; @@ -109,27 +91,9 @@ export const LoginModal: React.FC = ({ isOpen, onClose, onSwitc // Lưu phiên đăng nhập localStorage.setItem('token', data.access_token); localStorage.setItem('user', JSON.stringify(data.user)); - - // Tự động gia nhập tour nếu có pendingInviteToken - const pendingInviteToken = localStorage.getItem('pendingInviteToken'); - if (pendingInviteToken) { - try { - const joinRes = await fetch(`/api/v1/tours/join-by-token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${data.access_token}`, - }, - body: JSON.stringify({ token: pendingInviteToken }), - }); - if (joinRes.ok) { - localStorage.removeItem('pendingInviteToken'); - } - } catch (e) { - console.error('Lỗi tự động gia nhập:', e); - } - } + console.log('[LoginModal] Email/password login successful'); + // Regular login - no auto-join for tour if (onLoginSuccess) { onLoginSuccess(data.user); } diff --git a/frontend/src/components/PublicPhotoModal.tsx b/frontend/src/components/PublicPhotoModal.tsx index b9b0434..8a4612f 100644 --- a/frontend/src/components/PublicPhotoModal.tsx +++ b/frontend/src/components/PublicPhotoModal.tsx @@ -390,15 +390,24 @@ export const PublicPhotoModal: React.FC = ({ {/* Backdrop */}
{ + e.stopPropagation(); + onClose(); + }} /> {/* Container */} -
+
e.stopPropagation()} + > {/* Close Button Mobile/Desktop */}
{/* Info & Timeline overlay inside photo panel */} -
+
{/* Timeline scroll */} {photoGroup && photoGroup.length > 1 && ( -
+
Lịch sử ảnh tại vị trí này ({photoGroup.length}) -
+
{photoGroup.map((p) => { const isActive = p.id === photo.id; return (
{isAuthorized && ( +
+
+ ); + } + + // When admin closes the modal, navigate back to user dashboard + const handleCloseModal = () => { + onNavigate('dashboard'); + }; + + return ( +
+ {/* Header with toggle button */} +
+
+ +

+ 🛡️ Admin Dashboard +

+
+ +
+ + {/* Modal shown full screen */} +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/ExploreMap.tsx b/frontend/src/pages/ExploreMap.tsx index 3327371..6e85ec0 100644 --- a/frontend/src/pages/ExploreMap.tsx +++ b/frontend/src/pages/ExploreMap.tsx @@ -61,7 +61,11 @@ function MapTracker() { return null; } -export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void }) => { +export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess, onGoToDashboard }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void, onGoToDashboard?: () => void }) => { + // Check if user is logged in (real user) or is a guest + const guestToken = localStorage.getItem('guest_token'); + const isLoggedInOrGuest = user || guestToken; + // Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa const publicTours = useTourStore(state => state.publicTours); const fetchPublicTours = useTourStore(state => state.fetchPublicTours); @@ -701,7 +705,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, {/* Nhóm bên phải: Các thao tác người dùng */}
{/* Nút Ảnh của tôi */} - {user && ( + {isLoggedInOrGuest && (
{/* Nút quản lý người dùng cho Admin */} - {user && ( + {user?.isAdmin && ( )} + {/* Nút Bảng điều khiển của tôi - chỉ hiển thị cho người dùng đã đăng nhập (không phải khách) */} + {user && !localStorage.getItem('guest_token') && onGoToDashboard && ( + + )} + {/* Nút đăng xuất */} {onLogout && (
diff --git a/frontend/src/pages/JoinTourPage.tsx b/frontend/src/pages/JoinTourPage.tsx index a4eb6dd..ee874e3 100644 --- a/frontend/src/pages/JoinTourPage.tsx +++ b/frontend/src/pages/JoinTourPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useRef } from 'react'; import { Compass, Loader2, LogIn, UserPlus, AlertCircle, CheckCircle } from 'lucide-react'; -import { LoginModal } from '../components/LoginModal'; +import { JoinTourLoginModal } from '../components/JoinTourLoginModal'; interface JoinTourPageProps { onLoginSuccess: (user: any) => void; @@ -43,6 +43,7 @@ export const JoinTourPage: React.FC = ({ onLoginSuccess, onGo setLoading(true); setError(''); try { + console.log('[JoinTour] Attempting to join with token'); const res = await fetch('/api/v1/tours/join-by-token', { method: 'POST', headers: { @@ -54,28 +55,31 @@ export const JoinTourPage: React.FC = ({ onLoginSuccess, onGo const data = await res.json(); if (!res.ok) { - throw new Error(data.message || 'Không thể gia nhập tour.'); + console.error('[JoinTour] Join failed with status', res.status, 'message:', data.message); + // Show real backend error message + throw new Error(data.message || `Không thể gia nhập tour (${res.status}). Vui lòng kiểm tra lại mã lời mời.`); } + console.log('[JoinTour] Join successful, message:', data.message); setSuccessMsg(data.message || 'Bạn đã tham gia tour thành công!'); setTourId(data.tourId); // Đợi 500ms để đảm bảo các thay đổi cache ở backend hoàn tất trước khi tiếp tục await new Promise((resolve) => setTimeout(resolve, 500)); localStorage.removeItem('pendingInviteToken'); } catch (err: any) { - setError(err.message || 'Đã xảy ra lỗi.'); + console.error('[JoinTour] Error:', err.message); + setError(err.message || 'Đã xảy ra lỗi khi gia nhập tour.'); + // Keep the pendingInviteToken in localStorage so user can retry } finally { setLoading(false); } }; - const handleSuccessLogin = (user: any) => { + const handleJoinSuccess = (user: any, tourData: any) => { + console.log('[JoinTour] Join successful via modal, tourId:', tourData.tourId); + setTourId(tourData.tourId); + setSuccessMsg(tourData.message || 'Bạn đã tham gia tour thành công!'); onLoginSuccess(user); - const token = localStorage.getItem('token'); - const pendingToken = localStorage.getItem('pendingInviteToken') || inviteToken; - if (token && pendingToken) { - handleJoinTour(pendingToken, token); - } }; const isLoggedIn = !!localStorage.getItem('token'); @@ -175,11 +179,12 @@ export const JoinTourPage: React.FC = ({ onLoginSuccess, onGo
- setIsLoginOpen(false)} + inviteToken={inviteToken || ''} onSwitchToSignup={onGoToSignup} - onLoginSuccess={handleSuccessLogin} + onJoinSuccess={handleJoinSuccess} />
); diff --git a/frontend/src/pages/MemberDashboard.tsx b/frontend/src/pages/MemberDashboard.tsx index 2649b6c..ffbb329 100644 --- a/frontend/src/pages/MemberDashboard.tsx +++ b/frontend/src/pages/MemberDashboard.tsx @@ -45,6 +45,21 @@ export const MemberDashboard: React.FC = ({ onExploreTours, onViewTour }) => { + // GUARD: Prevent guest users from accessing dashboard + const guestToken = localStorage.getItem('guest_token'); + const token = localStorage.getItem('token'); + if (guestToken && !token) { + // This is a guest user - redirect them by triggering onLogout + // which will clear everything and redirect to landing + console.warn('Guest user attempted to access MemberDashboard - redirecting to home'); + // Clear guest tokens + localStorage.removeItem('guest_token'); + localStorage.removeItem('guest_user'); + // Redirect to home + window.location.href = '/'; + return null; + } + const notify = useNotification(); const confirm = useConfirm(); const { t, lang, changeLanguage } = useTranslation(); @@ -130,7 +145,7 @@ export const MemberDashboard: React.FC = ({ setShareStatus(data); notify({ title: 'Thành công', - message: isEnabled ? 'Đã bật chia sẻ hành trình cứu hộ.' : 'Đã tắt chia sẻ.', + message: isEnabled ? t('emergencySharingEnabled') : t('emergencySharingDisabled'), type: 'success' }); } @@ -337,9 +352,15 @@ export const MemberDashboard: React.FC = ({ // Fetch connections, tours, and photos on mount useEffect(() => { - fetchPublicTours(); - fetchConnections(); - fetchPhotos(); + Promise.all([ + fetchPublicTours().catch(err => { + console.error('[MemberDashboard] fetchPublicTours failed:', err.message); + // If fetch fails due to anonymous user rejection, don't crash + // The page is already loaded + }), + fetchConnections(), + fetchPhotos() + ]); }, []); const fetchConnectionsRef = useRef<() => Promise>(null as any); @@ -560,7 +581,7 @@ export const MemberDashboard: React.FC = ({ if (res.ok) { notify({ title: 'Đã cập nhật', - message: `Mối quan hệ đã được chuyển sang nhóm: ${type === 'FAMILY' ? 'Gia đình' : 'Bạn bè'}.`, + message: `${t('changeConnectionType')}${type === 'FAMILY' ? t('familyGroup') : t('friends')}.`, type: 'success' }); fetchConnections(); @@ -573,8 +594,8 @@ export const MemberDashboard: React.FC = ({ // Remove connection const handleRemoveConnection = async (connId: string, targetName: string) => { const isConfirmed = await confirm({ - title: 'Hủy kết nối', - message: `Bạn có chắc chắn muốn hủy kết nối với ${targetName}?` + title: t('disconnectTitle'), + message: `${t('disconnectConfirm')} ${targetName}?` }); if (isConfirmed) { @@ -586,7 +607,7 @@ export const MemberDashboard: React.FC = ({ if (res.ok) { notify({ title: 'Thành công', - message: 'Đã hủy kết nối thành công.', + message: t('disconnectSuccess'), type: 'success' }); fetchConnections(); @@ -595,7 +616,7 @@ export const MemberDashboard: React.FC = ({ } } } catch (err) { - console.error('Lỗi hủy kết nối:', err); + console.error(t('disconnectError'), err); } } }; @@ -684,7 +705,7 @@ export const MemberDashboard: React.FC = ({ const getConnectionStatusText = (targetId: string) => { const isConnected = connections.find(c => c.targetUser?.id === targetId); if (isConnected) { - return isConnected.type === 'FAMILY' ? 'Gia đình' : 'Bạn bè'; + return isConnected.type === 'FAMILY' ? t('familyGroup') : t('friends'); } const isPendingReceived = receivedRequests.find(r => r.requester?.id === targetId); if (isPendingReceived) return 'Chờ bạn duyệt'; @@ -805,7 +826,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') => className="w-full py-4 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold flex items-center justify-center gap-2 shadow-lg active:scale-98 transition-all" > - Khám phá Bản đồ Tour + {t('exploreTourMap')}
@@ -823,12 +844,12 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
- Hành trình của tôi + {t('myItineraries')} {(unreadTourSenders.length > 0 || unreadTourChats.length > 0) && ( )} - Xem và quản lý các chuyến đi + {t('toursManagementDesc')}
@@ -850,8 +871,8 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
- Thư viện ảnh - Kho ảnh gốc của bạn từ các tour + {t('photoGallery')} + {t('photoGalleryDesc')}
@@ -876,8 +897,8 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
- Danh sách bạn bè - Bạn bè, gia đình, yêu cầu chờ duyệt + {t('friendsList')} + {t('manageFriendsDesc')}
@@ -962,8 +983,8 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>

- {activeTab === 'tours' && 'Hành trình của tôi'} - {activeTab === 'connections' && 'Danh sách bạn bè'} + {activeTab === 'tours' && t('myItineraries')} + {activeTab === 'connections' && t('friendsList')} {activeTab === 'chats' && 'Trò chuyện trực tiếp'}

@@ -1096,7 +1117,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') => >
- Thư viện ảnh + {t('photoGallery')}
{photos.length} @@ -1135,7 +1156,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') => >
- Danh sách bạn bè + {t('friendsList')}
{receivedRequests.length > 0 && ( @@ -1188,7 +1209,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>

- Hành trình của tôi + {t('myItineraries')}

Danh sách các chuyến đi bạn tham gia (với vai trò chủ sở hữu, quản lý hoặc thành viên).

@@ -1331,9 +1352,9 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>

- Danh sách bạn bè + {t('friendsList')}

-

Quản lý các mối quan hệ bạn bè, gia đình, duyệt các yêu cầu kết nối từ thành viên khác.

+

{t('manageRelations')}

{/* Sub-tabs for connections */} @@ -1382,7 +1403,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>

Chưa có kết nối nào

-

Bạn chưa kết nối với ai. Hãy chuyển sang tìm kiếm để gửi lời mời.

+

{t('noConnections')}

@@ -1431,7 +1452,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') => onChange={(e) => handleChangeConnectionType(conn.id, e.target.value as any)} className="bg-slate-800 text-slate-200 border border-slate-700/80 rounded-md text-[10px] px-2 py-1 outline-none font-bold cursor-pointer hover:bg-slate-750 transition-colors" > - + @@ -1452,7 +1473,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') => @@ -1474,7 +1495,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') => setSearchQuery(e.target.value)} className="w-full bg-slate-950 border border-slate-800/80 rounded-2xl py-3 pl-11 pr-4 text-sm text-white placeholder-slate-500 outline-none focus:border-indigo-500/80 transition-all duration-150" @@ -1483,11 +1504,11 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') => {searchingUsers ? (
- Đang tìm kiếm... + {t('searching')}...
) : searchQuery.trim().length < 2 ? (
- Vui lòng nhập tối thiểu 2 ký tự để tìm kiếm thành viên. + {t('minCharsRequired')}
) : searchResults.length === 0 ? (
@@ -1522,7 +1543,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
{statusText ? ( diff --git a/frontend/src/pages/TourDetailPage.tsx b/frontend/src/pages/TourDetailPage.tsx index 44db7c8..c965398 100644 --- a/frontend/src/pages/TourDetailPage.tsx +++ b/frontend/src/pages/TourDetailPage.tsx @@ -3170,6 +3170,7 @@ export const TourDetailPage = ({ onClose={() => setIsAddPhotoOpen(false)} tourId={currentTour.id} onSuccess={() => fetchTour(currentTour.id)} + isPublicView={isPublicView} /> )} diff --git a/frontend/src/store/useTourStore.ts b/frontend/src/store/useTourStore.ts index b8e44b7..78185cd 100644 --- a/frontend/src/store/useTourStore.ts +++ b/frontend/src/store/useTourStore.ts @@ -92,15 +92,30 @@ export const useTourStore = create((set, get) => ({ const token = localStorage.getItem('token'); if (!token) return; - const response = await fetch(`/api/v1/tours/explore`, { - headers: { - 'Authorization': `Bearer ${token}` + try { + const response = await fetch(`/api/v1/tours/explore`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + // If response is 401 or 403, the user is likely anonymous and rejected by backend + if (response.status === 401 || response.status === 403) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Tài khoản khách không có quyền truy cập'); } - }); - - if (!response.ok) return; - const data = await response.json(); - set({ publicTours: data }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + set({ publicTours: data }); + } catch (error: any) { + console.error('[fetchPublicTours] Error:', error.message); + // Re-throw the error so MemberDashboard can catch it and redirect + throw error; + } }, fetchPublicTourDetails: async (tourId: string) => { try { diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 3612001..b2d293f 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -1,6 +1,7 @@ import type { Config } from 'tailwindcss'; const config: Config = { + darkMode: 'class', content: [ './index.html', './src/**/*.{js,ts,jsx,tsx}', diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0ed5f35..70d65b2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -31,15 +31,15 @@ export default defineConfig(({ mode }) => { https: httpsConfig, proxy: { '/api': { - target: 'http://localhost:3001', + target: env.VITE_BACKEND_URL || 'http://localhost:3001', changeOrigin: true, }, '/uploads': { - target: 'http://localhost:3001', + target: env.VITE_BACKEND_URL || 'http://localhost:3001', changeOrigin: true, }, '/socket.io': { - target: 'http://localhost:3001', + target: env.VITE_BACKEND_URL || 'http://localhost:3001', ws: true, changeOrigin: true, },