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
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" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-BwN9EkVY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-sqDjZKsa.css">
<script type="module" crossorigin src="/assets/index-BX4qY0bE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-pT9caalc.css">
</head>
<body>
<body>
<div id="root"></div>
</body>
</html>
+1 -1
View File
@@ -7,7 +7,7 @@
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title>
</head>
<body>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
+41 -7
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 { useNotification } from '@/hooks/useNotification';
@@ -22,6 +22,12 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
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) => {
setError('');
@@ -41,16 +47,39 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
localStorage.setItem('token', data.access_token);
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 {
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 }),
body: JSON.stringify({ token: tokenToUse }),
});
console.log('[JoinTourLogin] Join response status:', joinRes.status);
@@ -69,6 +98,9 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
}
onClose();
} 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
const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`;
console.error('[JoinTourLogin] Join failed:', errorMessage);
@@ -107,7 +139,9 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
localStorage.setItem('token', data.access_token);
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 {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
@@ -115,7 +149,7 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: inviteToken }),
body: JSON.stringify({ token: tokenToUse }),
});
const joinData = await joinRes.json().catch(() => ({}));
@@ -293,4 +327,4 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
</div>
</div>
);
};
};
+60 -4
View File
@@ -31,13 +31,69 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
localStorage.setItem('token', data.access_token);
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');
// Regular login - no auto-join for tour
if (onLoginSuccess) {
onLoginSuccess(data.user);
// 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
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
}
onClose();
} catch (err: any) {
console.error('[OAuth] Login error:', err);
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
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(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
@@ -28,9 +43,10 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
if (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');
if (systemToken && !hasJoinedRef.current) {
const guestToken = localStorage.getItem('guest_token');
if (systemToken && !guestToken && !hasJoinedRef.current) {
hasJoinedRef.current = true;
handleJoinTour(token, systemToken);
}
@@ -82,7 +98,7 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
onLoginSuccess(user);
};
const isLoggedIn = !!localStorage.getItem('token');
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
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">
+72 -72
View File
@@ -34,38 +34,36 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
const [fade2, setFade2] = useState(false);
const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
const [touchOffsetX, setTouchOffsetX] = useState(0);
const touchStartXRef = useRef(0);
const isSwipingRef = useRef(false);
const [touchOffsetX, setTouchOffsetX] = useState(0);
const touchStartXRef = useRef(0);
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');
const handleTouchStart = (e: React.TouchEvent) => {
touchStartXRef.current = e.touches[0].clientX;
isSwipingRef.current = true;
};
const handleTouchStart = (e: React.TouchEvent) => {
touchStartXRef.current = e.touches[0].clientX;
isSwipingRef.current = true;
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isSwipingRef.current) return;
const currentX = e.touches[0].clientX;
const diffX = currentX - touchStartXRef.current;
// Clamp the translation to [-70, 70] to ensure the background never shows white/black gaps
const clampedX = Math.max(-70, Math.min(70, diffX));
setTouchOffsetX(clampedX);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isSwipingRef.current) return;
const currentX = e.touches[0].clientX;
const diffX = currentX - touchStartXRef.current;
// Allow panning within bounds (for panorama viewing)
const clampedX = Math.max(maxPanX, Math.min(0, diffX));
setTouchOffsetX(clampedX);
};
const handleTouchEnd = () => {
if (!isSwipingRef.current) return;
isSwipingRef.current = false;
// Threshold lowered to 50px for better swipe responsiveness on mobile screens
if (touchOffsetX > 50 && publicPhotos.length > 0) {
setCurrentBgIndex((prev) => (prev - 1 + publicPhotos.length) % publicPhotos.length);
} else if (touchOffsetX < -50 && publicPhotos.length > 0) {
setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length);
}
setTouchOffsetX(0);
};
const handleTouchEnd = () => {
if (!isSwipingRef.current) return;
isSwipingRef.current = false;
// Reset panning position when touch ends
setTouchOffsetX(0);
};
useEffect(() => {
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
@@ -264,50 +262,52 @@ notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
className="absolute inset-y-0 left-[-15%] right-[-15%] cursor-grab active:cursor-grabbing"
style={{
transform: `translateX(${touchOffsetX}px)`,
transition: touchOffsetX === 0 ? 'transform 0.3s ease-out' : 'none',
touchAction: 'none'
}}
>
{/* Slot 1 */}
{bg1 && (
<img
key={bg1}
src={bg1}
draggable="false"
onContextMenu={(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 ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
style={{
opacity: fade1 ? 0.8 : 0,
}}
alt="Travel Background 1"
/>
)}
{/* Slot 2 */}
{bg2 && (
<img
key={bg2}
src={bg2}
draggable="false"
onContextMenu={(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 ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
style={{
opacity: fade2 ? 0.8 : 0,
}}
alt="Travel Background 2"
/>
)}
</div>
<div
className="absolute inset-y-0 left-0 right-0 cursor-grab active:cursor-grabbing"
style={{
transform: `translateX(${touchOffsetX}px)`,
transition: touchOffsetX === 0 ? 'transform 0.3s ease-out' : 'none',
touchAction: 'none'
}}
>
{/* Slot 1 */}
{bg1 && (
<img
key={bg1}
src={bg1}
draggable="false"
onContextMenu={(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 ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
style={{
opacity: fade1 ? 0.8 : 0,
transform: `scale(${panScale}) translateX(-${(panScale - 1) * 50}%)`,
}}
alt="Travel Background 1"
/>
)}
{/* Slot 2 */}
{bg2 && (
<img
key={bg2}
src={bg2}
draggable="false"
onContextMenu={(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 ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
style={{
opacity: fade2 ? 0.8 : 0,
transform: `scale(${panScale}) translateX(-${(panScale - 1) * 50}%)`,
}}
alt="Travel Background 2"
/>
)}
</div>
</div>
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
+8
View File
@@ -43,6 +43,7 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
// Tự động gia nhập tour nếu có pendingInviteToken
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
console.log('[SignupPage] Auto-joining tour with pending token after Google signup');
await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
@@ -50,6 +51,13 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
Authorization: `Bearer ${data.access_token}`,
},
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));
}
+26 -22
View File
@@ -23,27 +23,31 @@ export default defineConfig(({ mode }) => {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3002,
host: true,
// Cho phép các host từ file .env
allowedHosts: env.ALLOWED_HOSTS ? env.ALLOWED_HOSTS.split(',') : true,
https: httpsConfig,
proxy: {
'/api': {
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': {
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
changeOrigin: true,
},
'/socket.io': {
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
ws: true,
changeOrigin: true,
},
},
},
server: {
port: 3002,
host: true,
// Cho phép các host từ file .env
allowedHosts: env.ALLOWED_HOSTS ? env.ALLOWED_HOSTS.split(',') : true,
https: httpsConfig,
proxy: {
'/api': {
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': {
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
changeOrigin: true,
},
'/socket.io': {
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
ws: true,
changeOrigin: true,
},
},
headers: {
'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
'Cross-Origin-Embedder-Policy': 'unsafe-none',
},
},
};
});