fix: login với Google Oauth lỗi token invite

This commit is contained in:
2026-06-22 20:08:11 +07:00
parent acf867a375
commit 09b1ee882d
30 changed files with 246 additions and 122 deletions
+6
View File
@@ -1739,6 +1739,7 @@ class TourController {
async joinByToken(@Body() body: { token: string }, @Req() req: any) { async joinByToken(@Body() body: { token: string }, @Req() req: any) {
const { token } = body; const { token } = body;
console.log('[joinByToken] Request received, token length:', token ? token.length : 0); console.log('[joinByToken] Request received, token length:', token ? token.length : 0);
console.log('[joinByToken] Token received (full):', token);
if (!token) { if (!token) {
console.error('[joinByToken] No token provided'); console.error('[joinByToken] No token provided');
@@ -1756,6 +1757,11 @@ class TourController {
// Check how many invitations exist in database for debugging // Check how many invitations exist in database for debugging
const totalInvitations = await this.prisma.tourInvitation.count(); const totalInvitations = await this.prisma.tourInvitation.count();
console.log('[joinByToken] Total invitations in database:', totalInvitations); console.log('[joinByToken] Total invitations in database:', totalInvitations);
// List all tokens in DB for comparison (only in dev)
const allInvitations = await this.prisma.tourInvitation.findMany({ select: { token: true, email: true, tourId: true } });
console.log('[joinByToken] All invitation tokens:', allInvitations);
throw new NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.'); throw new NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.');
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -6,10 +6,10 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script src="https://accounts.google.com/gsi/client" async defer></script> <script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title> <title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-BwN9EkVY.js"></script> <script type="module" crossorigin src="/assets/index-BX4qY0bE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-sqDjZKsa.css"> <link rel="stylesheet" crossorigin href="/assets/index-pT9caalc.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
+1 -1
View File
@@ -7,7 +7,7 @@
<script src="https://accounts.google.com/gsi/client" async defer></script> <script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title> <title>Travel Planner</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
+40 -6
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2, LogIn } from 'lucide-react'; import { X, Mail, Lock, ArrowRight, Loader2, LogIn } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
@@ -23,6 +23,12 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const notify = useNotification(); const notify = useNotification();
// Ref to store the latest inviteToken to avoid stale closure issue
const inviteTokenRef = useRef(inviteToken);
useEffect(() => {
inviteTokenRef.current = inviteToken;
}, [inviteToken]);
const handleGoogleLogin = async (googleResponse: any) => { const handleGoogleLogin = async (googleResponse: any) => {
setError(''); setError('');
setIsLoading(true); setIsLoading(true);
@@ -41,16 +47,39 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
localStorage.setItem('token', data.access_token); localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user)); localStorage.setItem('user', JSON.stringify(data.user));
// Attempt to join tour with the token // Use the latest inviteToken from ref
let currentToken = inviteTokenRef.current;
console.log('[JoinTourLogin] Attempting to join with token:', currentToken?.substring(0, 10) + '...');
// Also check pendingInviteToken as fallback
let pendingToken = localStorage.getItem('pendingInviteToken');
// If no token found, try to get from URL
if (!currentToken && !pendingToken) {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
if (urlToken) {
currentToken = urlToken;
localStorage.setItem('pendingInviteToken', urlToken);
console.log('[JoinTourLogin] Using token from URL params:', urlToken.substring(0, 10) + '...');
}
}
const tokenToUse = currentToken || pendingToken;
if (!tokenToUse) {
console.error('[JoinTourLogin] No invite token available');
throw new Error('Không có mã lời mời để tham gia tour');
}
try { try {
console.log('[JoinTourLogin] Attempting to join with token:', inviteToken.substring(0, 10) + '...');
const joinRes = await fetch(`/api/v1/tours/join-by-token`, { const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`, Authorization: `Bearer ${data.access_token}`,
}, },
body: JSON.stringify({ token: inviteToken }), body: JSON.stringify({ token: tokenToUse }),
}); });
console.log('[JoinTourLogin] Join response status:', joinRes.status); console.log('[JoinTourLogin] Join response status:', joinRes.status);
@@ -69,6 +98,9 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
} }
onClose(); onClose();
} else { } else {
// Token invalid or expired - clear it and show error
console.log('[JoinTourLogin] Token invalid, clearing from storage');
localStorage.removeItem('pendingInviteToken');
// Email mismatch or other error // Email mismatch or other error
const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`; const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`;
console.error('[JoinTourLogin] Join failed:', errorMessage); console.error('[JoinTourLogin] Join failed:', errorMessage);
@@ -107,7 +139,9 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
localStorage.setItem('token', data.access_token); localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user)); localStorage.setItem('user', JSON.stringify(data.user));
// Attempt to join tour // Use the latest inviteToken from ref
const tokenToUse = inviteTokenRef.current || localStorage.getItem('pendingInviteToken');
try { try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, { const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST', method: 'POST',
@@ -115,7 +149,7 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`, Authorization: `Bearer ${data.access_token}`,
}, },
body: JSON.stringify({ token: inviteToken }), body: JSON.stringify({ token: tokenToUse }),
}); });
const joinData = await joinRes.json().catch(() => ({})); const joinData = await joinRes.json().catch(() => ({}));
+56
View File
@@ -31,13 +31,69 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
localStorage.setItem('token', data.access_token); localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user)); localStorage.setItem('user', JSON.stringify(data.user));
// Remove guest tokens to ensure clean real user session
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
console.log('[OAuth] Google login successful'); console.log('[OAuth] Google login successful');
// Check if there's a pending invite token to join tour
// Try localStorage first, then fallback to URL parameter
let pendingInviteToken = localStorage.getItem('pendingInviteToken');
console.log('[OAuth] pendingInviteToken from localStorage:', pendingInviteToken ? pendingInviteToken.substring(0, 20) + '...' : 'none');
// If no token in localStorage, try to get from URL (in case of race condition)
if (!pendingInviteToken) {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
if (urlToken) {
pendingInviteToken = urlToken;
localStorage.setItem('pendingInviteToken', urlToken);
console.log('[OAuth] Using token from URL params:', urlToken.substring(0, 20) + '...');
}
}
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 }),
});
const joinData = await joinRes.json().catch(() => ({}));
if (joinRes.ok) {
localStorage.removeItem('pendingInviteToken');
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
} else {
// If invitation is invalid/expired, clear it and redirect to dashboard
console.log('[OAuth] Token invalid or expired, clearing and redirecting to dashboard');
localStorage.removeItem('pendingInviteToken');
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
}
} catch (joinErr: any) {
console.error('[OAuth] Join tour after login failed:', joinErr);
localStorage.removeItem('pendingInviteToken');
// Still redirect to dashboard on error
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
}
} else {
// Regular login - no auto-join for tour // Regular login - no auto-join for tour
if (onLoginSuccess) { if (onLoginSuccess) {
onLoginSuccess(data.user); onLoginSuccess(data.user);
} }
onClose(); onClose();
}
} catch (err: any) { } catch (err: any) {
console.error('[OAuth] Login error:', err); console.error('[OAuth] Login error:', err);
setError(err.message); setError(err.message);
+19 -3
View File
@@ -20,6 +20,21 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
// Guard chống React StrictMode chạy effect 2 lần trong dev mode // Guard chống React StrictMode chạy effect 2 lần trong dev mode
const hasJoinedRef = useRef(false); const hasJoinedRef = useRef(false);
// Sync inviteToken from URL when modal opens to ensure fresh value
useEffect(() => {
if (isLoginOpen) {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (token) {
setInviteToken(token);
localStorage.setItem('pendingInviteToken', token);
}
}
}, [isLoginOpen]);
// Check for real authenticated user (not guest)
const hasRealToken = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const token = params.get('token'); const token = params.get('token');
@@ -28,9 +43,10 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
if (token) { if (token) {
localStorage.setItem('pendingInviteToken', token); localStorage.setItem('pendingInviteToken', token);
// Nếu đã đăng nhập, tự động thực hiện join // Nếu đã đăng nhập (có token thực, không phải guest), tự động thực hiện join
const systemToken = localStorage.getItem('token'); const systemToken = localStorage.getItem('token');
if (systemToken && !hasJoinedRef.current) { const guestToken = localStorage.getItem('guest_token');
if (systemToken && !guestToken && !hasJoinedRef.current) {
hasJoinedRef.current = true; hasJoinedRef.current = true;
handleJoinTour(token, systemToken); handleJoinTour(token, systemToken);
} }
@@ -82,7 +98,7 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
onLoginSuccess(user); onLoginSuccess(user);
}; };
const isLoggedIn = !!localStorage.getItem('token'); const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 font-sans"> <div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 font-sans">
+14 -14
View File
@@ -34,9 +34,11 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
const [fade2, setFade2] = useState(false); const [fade2, setFade2] = useState(false);
const [activeSlot, setActiveSlot] = useState<1 | 2>(1); const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
const [touchOffsetX, setTouchOffsetX] = useState(0); const [touchOffsetX, setTouchOffsetX] = useState(0);
const touchStartXRef = useRef(0); const touchStartXRef = useRef(0);
const isSwipingRef = useRef(false); const isSwipingRef = useRef(false);
const panScale = 1.25;
const maxPanX = typeof window !== 'undefined' ? -(window.innerWidth * (panScale - 1)) : -250;
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token'); const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
@@ -50,20 +52,16 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
const currentX = e.touches[0].clientX; const currentX = e.touches[0].clientX;
const diffX = currentX - touchStartXRef.current; const diffX = currentX - touchStartXRef.current;
// Clamp the translation to [-70, 70] to ensure the background never shows white/black gaps // Allow panning within bounds (for panorama viewing)
const clampedX = Math.max(-70, Math.min(70, diffX)); const clampedX = Math.max(maxPanX, Math.min(0, diffX));
setTouchOffsetX(clampedX); setTouchOffsetX(clampedX);
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
if (!isSwipingRef.current) return; if (!isSwipingRef.current) return;
isSwipingRef.current = false; isSwipingRef.current = false;
// Threshold lowered to 50px for better swipe responsiveness on mobile screens
if (touchOffsetX > 50 && publicPhotos.length > 0) { // Reset panning position when touch ends
setCurrentBgIndex((prev) => (prev - 1 + publicPhotos.length) % publicPhotos.length);
} else if (touchOffsetX < -50 && publicPhotos.length > 0) {
setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length);
}
setTouchOffsetX(0); setTouchOffsetX(0);
}; };
@@ -264,8 +262,8 @@ notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
<div <div
className="absolute inset-y-0 left-[-15%] right-[-15%] cursor-grab active:cursor-grabbing" className="absolute inset-y-0 left-0 right-0 cursor-grab active:cursor-grabbing"
style={{ style={{
transform: `translateX(${touchOffsetX}px)`, transform: `translateX(${touchOffsetX}px)`,
transition: touchOffsetX === 0 ? 'transform 0.3s ease-out' : 'none', transition: touchOffsetX === 0 ? 'transform 0.3s ease-out' : 'none',
@@ -273,18 +271,19 @@ notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải
}} }}
> >
{/* Slot 1 */} {/* Slot 1 */}
{bg1 && ( {bg1 && (
<img <img
key={bg1} key={bg1}
src={bg1} src={bg1}
draggable="false" draggable="false"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }} onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }} onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning ${ className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out ${
!isLoggedIn ? 'select-none pointer-events-none' : '' !isLoggedIn ? 'select-none pointer-events-none' : ''
}`} }`}
style={{ style={{
opacity: fade1 ? 0.8 : 0, opacity: fade1 ? 0.8 : 0,
transform: `scale(${panScale}) translateX(-${(panScale - 1) * 50}%)`,
}} }}
alt="Travel Background 1" alt="Travel Background 1"
/> />
@@ -298,11 +297,12 @@ notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải
draggable="false" draggable="false"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }} onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }} onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning ${ className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out ${
!isLoggedIn ? 'select-none pointer-events-none' : '' !isLoggedIn ? 'select-none pointer-events-none' : ''
}`} }`}
style={{ style={{
opacity: fade2 ? 0.8 : 0, opacity: fade2 ? 0.8 : 0,
transform: `scale(${panScale}) translateX(-${(panScale - 1) * 50}%)`,
}} }}
alt="Travel Background 2" alt="Travel Background 2"
/> />
+8
View File
@@ -43,6 +43,7 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
// Tự động gia nhập tour nếu có pendingInviteToken // Tự động gia nhập tour nếu có pendingInviteToken
const pendingInviteToken = localStorage.getItem('pendingInviteToken'); const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) { if (pendingInviteToken) {
console.log('[SignupPage] Auto-joining tour with pending token after Google signup');
await fetch(`/api/v1/tours/join-by-token`, { await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -50,6 +51,13 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
Authorization: `Bearer ${data.access_token}`, Authorization: `Bearer ${data.access_token}`,
}, },
body: JSON.stringify({ token: pendingInviteToken }), body: JSON.stringify({ token: pendingInviteToken }),
}).then(res => {
if (res.ok) {
localStorage.removeItem('pendingInviteToken');
console.log('[SignupPage] Successfully joined tour after Google signup');
} else {
console.warn('[SignupPage] Failed to join tour after Google signup:', res.status);
}
}).catch((e) => console.error('Lỗi tự động gia nhập:', e)); }).catch((e) => console.error('Lỗi tự động gia nhập:', e));
} }
+5 -1
View File
@@ -23,7 +23,7 @@ export default defineConfig(({ mode }) => {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
}, },
server: { server: {
port: 3002, port: 3002,
host: true, host: true,
// Cho phép các host từ file .env // Cho phép các host từ file .env
@@ -44,6 +44,10 @@ export default defineConfig(({ mode }) => {
changeOrigin: true, changeOrigin: true,
}, },
}, },
headers: {
'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
'Cross-Origin-Embedder-Policy': 'unsafe-none',
},
}, },
}; };
}); });