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
+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));
}