mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
Replace all bare useAuthStore(), useBlockingStore(), and useSuccessNotification() calls with individual field selectors. This prevents components from re-rendering when unrelated store fields change. - 21 useAuthStore usages across 20 files: individual selectors for 1-2 fields, useShallow for 3+ fields - 5 useBlockingStore usages: individual selectors - 2 useSuccessNotification usages: individual selectors
240 lines
9.4 KiB
TypeScript
240 lines
9.4 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate, useSearchParams } from 'react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useAuthStore } from '../store/auth';
|
|
import { useShallow } from 'zustand/shallow';
|
|
import { brandingApi } from '../api/branding';
|
|
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
|
|
|
|
// Validate redirect URL to prevent open redirect attacks
|
|
const getSafeRedirectUrl = (url: string | null): string => {
|
|
if (!url) return '/';
|
|
// Only allow relative paths starting with /
|
|
// Block protocol-relative URLs (//evil.com) and absolute URLs
|
|
if (!url.startsWith('/') || url.startsWith('//')) {
|
|
return '/';
|
|
}
|
|
// Additional check for encoded characters that could bypass validation
|
|
try {
|
|
const decoded = decodeURIComponent(url);
|
|
if (!decoded.startsWith('/') || decoded.startsWith('//') || decoded.includes('://')) {
|
|
return '/';
|
|
}
|
|
} catch {
|
|
return '/';
|
|
}
|
|
return url;
|
|
};
|
|
|
|
const MAX_RETRY_ATTEMPTS = 3;
|
|
const RETRY_COUNT_KEY = 'telegram_redirect_retry_count';
|
|
|
|
export default function TelegramRedirect() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const {
|
|
loginWithTelegram,
|
|
isAuthenticated,
|
|
isLoading: authLoading,
|
|
} = useAuthStore(
|
|
useShallow((state) => ({
|
|
loginWithTelegram: state.loginWithTelegram,
|
|
isAuthenticated: state.isAuthenticated,
|
|
isLoading: state.isLoading,
|
|
})),
|
|
);
|
|
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading');
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [retryCount, setRetryCount] = useState(() => {
|
|
const stored = sessionStorage.getItem(RETRY_COUNT_KEY);
|
|
return stored ? parseInt(stored, 10) : 0;
|
|
});
|
|
|
|
// Get branding for nice display
|
|
const { data: branding } = useQuery({
|
|
queryKey: ['branding'],
|
|
queryFn: brandingApi.getBranding,
|
|
staleTime: 60000,
|
|
});
|
|
|
|
const appName = branding ? branding.name : import.meta.env.VITE_APP_NAME || 'VPN';
|
|
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V';
|
|
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
|
|
|
|
// Get redirect target from URL params (validated)
|
|
const redirectTo = getSafeRedirectUrl(searchParams.get('redirect'));
|
|
|
|
useEffect(() => {
|
|
// If already authenticated, redirect immediately
|
|
if (isAuthenticated && !authLoading) {
|
|
setStatus('success');
|
|
setTimeout(() => navigate(redirectTo), 500);
|
|
return;
|
|
}
|
|
|
|
const initTelegram = async () => {
|
|
// Check if running in Telegram WebApp
|
|
const initData = getTelegramInitData();
|
|
|
|
if (!isInTelegramWebApp() || !initData) {
|
|
// Not in Telegram, show message and redirect to login
|
|
setStatus('not-telegram');
|
|
setTimeout(() => navigate('/login'), 2000);
|
|
return;
|
|
}
|
|
|
|
// Note: ready(), expand(), and theme CSS vars are already handled by SDK init in main.tsx
|
|
|
|
try {
|
|
await loginWithTelegram(initData);
|
|
setStatus('success');
|
|
|
|
// Small delay for nice UX
|
|
setTimeout(() => {
|
|
navigate(redirectTo);
|
|
}, 800);
|
|
} catch (err: unknown) {
|
|
console.error('Telegram auth failed:', err);
|
|
const error = err as { response?: { data?: { detail?: string } } };
|
|
setErrorMessage(error.response?.data?.detail || t('auth.telegramRequired'));
|
|
setStatus('error');
|
|
}
|
|
};
|
|
|
|
// Small delay to show loading screen
|
|
setTimeout(initTelegram, 300);
|
|
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t]);
|
|
|
|
// Handle retry with limit to prevent infinite loops
|
|
const handleRetry = () => {
|
|
if (retryCount >= MAX_RETRY_ATTEMPTS) {
|
|
setErrorMessage(t('telegramRedirect.maxRetries'));
|
|
sessionStorage.removeItem(RETRY_COUNT_KEY);
|
|
return;
|
|
}
|
|
const newCount = retryCount + 1;
|
|
setRetryCount(newCount);
|
|
sessionStorage.setItem(RETRY_COUNT_KEY, String(newCount));
|
|
setStatus('loading');
|
|
setErrorMessage('');
|
|
window.location.reload();
|
|
};
|
|
|
|
// Clear retry count on successful auth
|
|
useEffect(() => {
|
|
if (status === 'success') {
|
|
sessionStorage.removeItem(RETRY_COUNT_KEY);
|
|
}
|
|
}, [status]);
|
|
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center p-4">
|
|
{/* Background */}
|
|
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
|
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
|
|
|
|
<div className="relative w-full max-w-sm text-center">
|
|
{/* Logo */}
|
|
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 shadow-lg shadow-accent-500/30">
|
|
{branding?.has_custom_logo && logoUrl ? (
|
|
<img src={logoUrl} alt={appName} className="h-full w-full object-cover" />
|
|
) : (
|
|
<span className="text-3xl font-bold text-white">{logoLetter}</span>
|
|
)}
|
|
</div>
|
|
|
|
<h1 className="mb-2 text-2xl font-bold text-dark-50">{appName}</h1>
|
|
|
|
{/* Loading State */}
|
|
{status === 'loading' && (
|
|
<div className="mt-8">
|
|
<div className="border-3 mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-accent-500 border-t-transparent" />
|
|
<p className="text-dark-400">{t('auth.authenticating')}</p>
|
|
<p className="mt-2 text-sm text-dark-500">{t('common.loading')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Success State */}
|
|
{status === 'success' && (
|
|
<div className="mt-8">
|
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-success-500/20">
|
|
<svg
|
|
className="h-8 w-8 text-success-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-dark-200">{t('auth.loginSuccess')}</p>
|
|
<p className="mt-2 text-sm text-dark-500">{t('telegramRedirect.redirecting')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error State */}
|
|
{status === 'error' && (
|
|
<div className="mt-8">
|
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-error-500/20">
|
|
<svg
|
|
className="h-8 w-8 text-error-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</div>
|
|
<p className="mb-2 text-dark-200">{t('auth.loginFailed')}</p>
|
|
<p className="mb-6 text-sm text-error-400">{errorMessage}</p>
|
|
<div className="flex flex-col gap-3">
|
|
<button onClick={handleRetry} className="btn-primary w-full">
|
|
{t('auth.tryAgain')}
|
|
</button>
|
|
<button onClick={() => navigate('/login')} className="btn-secondary w-full">
|
|
{t('telegramRedirect.loginAlternative')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Not in Telegram State */}
|
|
{status === 'not-telegram' && (
|
|
<div className="mt-8">
|
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-warning-500/20">
|
|
<svg
|
|
className="h-8 w-8 text-warning-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<p className="mb-2 text-dark-200">{t('telegramRedirect.openInTelegram')}</p>
|
|
<p className="mb-6 text-sm text-dark-400">{t('telegramRedirect.openInTelegramDesc')}</p>
|
|
<p className="text-sm text-dark-500">{t('telegramRedirect.redirectToLogin')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Telegram branding */}
|
|
<div className="mt-12 flex items-center justify-center gap-2 text-dark-600">
|
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
|
</svg>
|
|
<span className="text-xs">Telegram Mini App</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|