feat: TelegramLoginButton with OIDC popup + legacy widget fallback

- Add oidc_enabled/oidc_client_id to TelegramWidgetConfig interface and fallback
- Add loginTelegramOIDC API method for id_token auth
- Add loginWithTelegramOIDC to auth store
- Rewrite TelegramLoginButton: OIDC popup when enabled, legacy widget otherwise
- Extend Window.Telegram type with Login SDK in vite-env.d.ts
This commit is contained in:
Fringg
2026-03-07 02:37:12 +03:00
parent 2c65ca8a7f
commit 91f0e9e2fc
5 changed files with 142 additions and 7 deletions

View File

@@ -49,6 +49,20 @@ export const authApi = {
return response.data; return response.data;
}, },
// Telegram OIDC authentication (popup flow with id_token)
loginTelegramOIDC: async (
idToken: string,
campaignSlug?: string | null,
referralCode?: string | null,
): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/oidc', {
id_token: idToken,
campaign_slug: campaignSlug || undefined,
referral_code: referralCode || undefined,
});
return response.data;
},
// Email login // Email login
loginEmail: async ( loginEmail: async (
email: string, email: string,

View File

@@ -29,6 +29,8 @@ export interface TelegramWidgetConfig {
radius: number; radius: number;
userpic: boolean; userpic: boolean;
request_access: boolean; request_access: boolean;
oidc_enabled: boolean;
oidc_client_id: string;
} }
export interface AnalyticsCounters { export interface AnalyticsCounters {
@@ -273,6 +275,8 @@ export const brandingApi = {
radius: 8, radius: 8,
userpic: true, userpic: true,
request_access: true, request_access: true,
oidc_enabled: false,
oidc_client_id: '',
}; };
} }
}, },

View File

@@ -1,7 +1,9 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { useAuthStore } from '../store/auth';
import { useNavigate } from 'react-router';
interface TelegramLoginButtonProps { interface TelegramLoginButtonProps {
referralCode?: string; referralCode?: string;
@@ -9,7 +11,11 @@ interface TelegramLoginButtonProps {
export default function TelegramLoginButton({ referralCode }: TelegramLoginButtonProps) { export default function TelegramLoginButton({ referralCode }: TelegramLoginButtonProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [oidcLoading, setOidcLoading] = useState(false);
const [oidcError, setOidcError] = useState('');
const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC);
const { data: widgetConfig } = useQuery<TelegramWidgetConfig>({ const { data: widgetConfig } = useQuery<TelegramWidgetConfig>({
queryKey: ['telegram-widget-config'], queryKey: ['telegram-widget-config'],
@@ -19,14 +25,68 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
const botUsername = const botUsername =
widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const isOIDC = widgetConfig?.oidc_enabled && widgetConfig?.oidc_client_id;
// Load widget script // OIDC callback handler
const handleOIDCCallback = useCallback(
async (data: { id_token?: string; error?: string }) => {
if (data.error || !data.id_token) {
setOidcError(data.error || t('auth.loginFailed'));
setOidcLoading(false);
return;
}
try {
setOidcLoading(true);
await loginWithTelegramOIDC(data.id_token);
navigate('/');
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } };
setOidcError(error.response?.data?.detail || t('common.error'));
} finally {
setOidcLoading(false);
}
},
[loginWithTelegramOIDC, navigate, t],
);
// Load OIDC script and init
useEffect(() => { useEffect(() => {
if (!containerRef.current || !botUsername || !widgetConfig) return; if (!isOIDC || !widgetConfig?.oidc_client_id) return;
const scriptId = 'telegram-login-oidc-script';
let script = document.getElementById(scriptId) as HTMLScriptElement | null;
const initTelegramLogin = () => {
if (window.Telegram?.Login) {
window.Telegram.Login.init(
{
client_id: widgetConfig.oidc_client_id,
request_access: widgetConfig.request_access ? ['write'] : undefined,
lang: document.documentElement.lang || 'en',
},
handleOIDCCallback,
);
}
};
if (!script) {
script = document.createElement('script');
script.id = scriptId;
script.src = 'https://oauth.telegram.org/js/telegram-login.js?3';
script.async = true;
script.onload = initTelegramLogin;
document.head.appendChild(script);
} else {
// Script already loaded, just re-init
initTelegramLogin();
}
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access, handleOIDCCallback]);
// Legacy widget effect (only when NOT OIDC)
useEffect(() => {
if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
const container = containerRef.current; const container = containerRef.current;
// Clear previous widget
while (container.firstChild) { while (container.firstChild) {
container.removeChild(container.firstChild); container.removeChild(container.firstChild);
} }
@@ -52,7 +112,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
container.removeChild(container.firstChild); container.removeChild(container.firstChild);
} }
}; };
}, [botUsername, widgetConfig]); }, [isOIDC, botUsername, widgetConfig]);
if (!botUsername || botUsername === 'your_bot') { if (!botUsername || botUsername === 'your_bot') {
return ( return (
@@ -64,7 +124,33 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
return ( return (
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<div ref={containerRef} className="flex justify-center" /> {/* OIDC mode: custom button that opens popup */}
{isOIDC ? (
<div className="flex flex-col items-center space-y-2">
<button
type="button"
onClick={() => {
setOidcError('');
if (window.Telegram?.Login) {
window.Telegram.Login.open();
}
}}
disabled={oidcLoading}
className="inline-flex items-center gap-2 rounded-lg bg-[#54a9eb] px-6 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[#4a96d2] disabled:opacity-50"
>
<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>
{oidcLoading
? t('common.loading')
: t('auth.loginWithTelegram', 'Sign in with Telegram')}
</button>
{oidcError && <p className="text-xs text-red-500">{oidcError}</p>}
</div>
) : (
/* Legacy widget mode: iframe-based widget */
<div ref={containerRef} className="flex justify-center" />
)}
<div className="text-center"> <div className="text-center">
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p> <p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>

View File

@@ -37,6 +37,7 @@ interface AuthState {
checkAdminStatus: () => Promise<void>; checkAdminStatus: () => Promise<void>;
loginWithTelegram: (initData: string) => Promise<void>; loginWithTelegram: (initData: string) => Promise<void>;
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>; loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
loginWithTelegramOIDC: (idToken: string) => Promise<void>;
loginWithEmail: (email: string, password: string) => Promise<void>; loginWithEmail: (email: string, password: string) => Promise<void>;
loginWithOAuth: ( loginWithOAuth: (
provider: string, provider: string,
@@ -285,6 +286,21 @@ export const useAuthStore = create<AuthState>()(
await get().checkAdminStatus(); await get().checkAdminStatus();
}, },
loginWithTelegramOIDC: async (idToken) => {
const campaignSlug = consumeCampaignSlug();
const referralCode = consumeReferralCode();
const response = await authApi.loginTelegramOIDC(idToken, campaignSlug, referralCode);
tokenStorage.setTokens(response.access_token, response.refresh_token);
set({
accessToken: response.access_token,
refreshToken: response.refresh_token,
user: response.user,
isAuthenticated: true,
pendingCampaignBonus: response.campaign_bonus || null,
});
await get().checkAdminStatus();
},
loginWithEmail: async (email, password) => { loginWithEmail: async (email, password) => {
const campaignSlug = consumeCampaignSlug(); const campaignSlug = consumeCampaignSlug();
const referralCode = consumeReferralCode(); const referralCode = consumeReferralCode();

15
src/vite-env.d.ts vendored
View File

@@ -19,8 +19,23 @@ interface TelegramWebAppGlobal {
offEvent?: (event: string, callback: () => void) => void; offEvent?: (event: string, callback: () => void) => void;
} }
/** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */
interface TelegramLoginGlobal {
init: (
options: {
client_id: string;
request_access?: string[];
lang?: string;
},
callback: (data: { id_token?: string; user?: Record<string, unknown>; error?: string }) => void,
) => void;
open: () => void;
auth: () => void;
}
interface Window { interface Window {
Telegram?: { Telegram?: {
WebApp?: TelegramWebAppGlobal; WebApp?: TelegramWebAppGlobal;
Login?: TelegramLoginGlobal;
}; };
} }