feat: add OAuth 2.0 login UI (Google, Yandex, Discord, VK)

- Add OAuthProvider type and extend User.auth_type union
- Add OAuth API methods (providers, authorize, callback)
- Add loginWithOAuth to auth store
- Create OAuthCallback page with state validation
- Create OAuthProviderIcon component with brand SVGs
- Add OAuth buttons to Login page between Telegram and Email
- Add OAuth callback route to App.tsx
- Add translations for ru, en, zh, fa
This commit is contained in:
Fringg
2026-02-07 01:59:05 +03:00
parent af25e6a1b8
commit 83aeae81b8
12 changed files with 329 additions and 6 deletions

View File

@@ -18,6 +18,8 @@ import { getAndClearReturnUrl } from '../utils/token';
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton';
import OAuthProviderIcon from '../components/OAuthProviderIcon';
import { saveOAuthState } from './OAuthCallback';
export default function Login() {
const { t } = useTranslation();
@@ -92,6 +94,29 @@ export default function Login() {
});
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true;
// Fetch enabled OAuth providers
const { data: oauthData } = useQuery({
queryKey: ['oauth-providers'],
queryFn: authApi.getOAuthProviders,
staleTime: 60000,
});
const oauthProviders = oauthData?.providers ?? [];
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
const handleOAuthLogin = async (provider: string) => {
setError('');
setOauthLoading(provider);
try {
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
saveOAuthState(state, provider);
window.location.href = authorize_url;
} catch {
setError(t('auth.oauthError', 'Authorization was denied or failed'));
setOauthLoading(null);
}
};
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
// If email auth is disabled but user came with ref param, redirect to bot
@@ -424,6 +449,38 @@ export default function Login() {
)}
</div>
{/* OAuth providers section */}
{oauthProviders.length > 0 && (
<>
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-dark-700" />
<span className="text-xs text-dark-500">{t('auth.or', 'or')}</span>
<div className="h-px flex-1 bg-dark-700" />
</div>
<div className="space-y-3">
{oauthProviders.map((provider) => (
<button
key={provider.name}
type="button"
onClick={() => handleOAuthLogin(provider.name)}
disabled={oauthLoading !== null}
className="hover:bg-dark-750 flex w-full items-center justify-center gap-3 rounded-xl border border-dark-700 bg-dark-800 px-4 py-3 text-sm font-medium text-dark-100 transition-colors hover:border-dark-600 disabled:opacity-50"
>
{oauthLoading === provider.name ? (
<span className="h-5 w-5 animate-spin rounded-full border-2 border-dark-400 border-t-white" />
) : (
<OAuthProviderIcon provider={provider.name} className="h-5 w-5" />
)}
{t(
`auth.continueWith${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`,
`Continue with ${provider.display_name}`,
)}
</button>
))}
</div>
</>
)}
{/* Email auth section - only when enabled */}
{isEmailAuthEnabled && (
<>