fix: login với Google Oauth lỗi token invite
@@ -1739,6 +1739,7 @@ class TourController {
|
||||
async joinByToken(@Body() body: { token: string }, @Req() req: any) {
|
||||
const { token } = body;
|
||||
console.log('[joinByToken] Request received, token length:', token ? token.length : 0);
|
||||
console.log('[joinByToken] Token received (full):', token);
|
||||
|
||||
if (!token) {
|
||||
console.error('[joinByToken] No token provided');
|
||||
@@ -1756,6 +1757,11 @@ class TourController {
|
||||
// Check how many invitations exist in database for debugging
|
||||
const totalInvitations = await this.prisma.tourInvitation.count();
|
||||
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.');
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 233 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -23,6 +23,12 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
|
||||
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('');
|
||||
setIsLoading(true);
|
||||
@@ -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(() => ({}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
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);
|
||||
};
|
||||
// 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"
|
||||
/>
|
||||
)}
|
||||
<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 animate-panning ${
|
||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
style={{
|
||||
opacity: fade2 ? 0.8 : 0,
|
||||
}}
|
||||
alt="Travel Background 2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 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 */}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||