fix: support OIDC mode in TelegramLinkWidget for account linking

TelegramLinkWidget used legacy widget only, ignoring OIDC config.
Now queries widgetConfig from backend, supports OIDC popup flow with
id_token, and falls back to bot_username from server config.
This commit is contained in:
Fringg
2026-03-09 06:23:02 +03:00
parent 6a9fdac75c
commit 880b2d45fe
2 changed files with 135 additions and 17 deletions

View File

@@ -248,10 +248,11 @@ export const authApi = {
return response.data; return response.data;
}, },
// Link Telegram account (Mini App initData or Login Widget data) // Link Telegram account (Mini App initData, OIDC id_token, or Login Widget data)
linkTelegram: async ( linkTelegram: async (
data: data:
| { init_data: string } | { init_data: string }
| { id_token: string }
| { | {
id: number; id: number;
first_name: string; first_name: string;

View File

@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { Card } from '@/components/data-display/Card'; import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
@@ -24,28 +25,126 @@ const isLinkableProvider = (provider: string): boolean =>
// SessionStorage key for Telegram link CSRF state // SessionStorage key for Telegram link CSRF state
export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state'; export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state';
/** Compact Telegram Login Widget for account linking (browser only). */ /** Telegram account linking widget (browser only). Supports OIDC popup and legacy widget. */
function TelegramLinkWidget() { function TelegramLinkWidget() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { showToast } = useToast(); const { showToast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; const [oidcLoading, setOidcLoading] = useState(false);
const [scriptLoaded, setScriptLoaded] = useState(false);
const mountedRef = useRef(true);
const { data: widgetConfig } = useQuery<TelegramWidgetConfig>({
queryKey: ['telegram-widget-config'],
queryFn: brandingApi.getTelegramWidgetConfig,
staleTime: 60000,
});
const botUsername =
widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const isOIDC = Boolean(widgetConfig?.oidc_enabled && widgetConfig?.oidc_client_id);
useEffect(() => { useEffect(() => {
if (!containerRef.current || !botUsername) return; mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// Shared handler for link result
const handleLinkResult = async (response: Awaited<ReturnType<typeof authApi.linkTelegram>>) => {
if (response.merge_required && response.merge_token) {
navigate(`/merge/${response.merge_token}`, { replace: true });
} else {
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
}
};
// OIDC callback handler (ref pattern to avoid stale closures)
const handleOIDCCallbackRef =
useRef<(data: { id_token?: string; error?: string }) => void>(undefined);
handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => {
if (!mountedRef.current) return;
if (data.error || !data.id_token) {
setOidcLoading(false);
showToast({
type: 'error',
message: data.error || t('profile.accounts.linkError'),
});
return;
}
try {
setOidcLoading(true);
const response = await authApi.linkTelegram({ id_token: data.id_token });
if (mountedRef.current) await handleLinkResult(response);
} catch (err: unknown) {
if (mountedRef.current) {
showToast({
type: 'error',
message: getErrorDetail(err) || t('profile.accounts.linkError'),
});
}
} finally {
if (mountedRef.current) setOidcLoading(false);
}
};
// Load OIDC script and init
useEffect(() => {
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: Number(widgetConfig.oidc_client_id) || widgetConfig.oidc_client_id,
request_access: widgetConfig.request_access ? ['write'] : undefined,
lang: document.documentElement.lang || 'en',
},
(data) => handleOIDCCallbackRef.current?.(data),
);
setScriptLoaded(true);
}
};
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();
script.onerror = () => {
if (mountedRef.current) {
showToast({ type: 'error', message: t('profile.accounts.linkError') });
}
};
document.head.appendChild(script);
} else {
initTelegramLogin();
}
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]);
// Legacy widget effect (only when NOT OIDC)
useEffect(() => {
if (isOIDC || !containerRef.current || !botUsername) return;
const container = containerRef.current; const container = containerRef.current;
while (container.firstChild) { while (container.firstChild) {
container.removeChild(container.firstChild); container.removeChild(container.firstChild);
} }
// Global callback invoked by the Telegram Login Widget
const callbackName = '__onTelegramLinkAuth'; const callbackName = '__onTelegramLinkAuth';
(window as unknown as Record<string, unknown>)[callbackName] = async ( (window as unknown as Record<string, unknown>)[callbackName] = async (
user: Record<string, unknown>, user: Record<string, unknown>,
) => { ) => {
if (!mountedRef.current) return;
try { try {
const response = await authApi.linkTelegram({ const response = await authApi.linkTelegram({
id: user.id as number, id: user.id as number,
@@ -56,17 +155,14 @@ function TelegramLinkWidget() {
auth_date: user.auth_date as number, auth_date: user.auth_date as number,
hash: user.hash as string, hash: user.hash as string,
}); });
if (response.merge_required && response.merge_token) { if (mountedRef.current) await handleLinkResult(response);
navigate(`/merge/${response.merge_token}`, { replace: true });
} else {
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
}
} catch (err: unknown) { } catch (err: unknown) {
showToast({ if (mountedRef.current) {
type: 'error', showToast({
message: getErrorDetail(err) || t('profile.accounts.linkError'), type: 'error',
}); message: getErrorDetail(err) || t('profile.accounts.linkError'),
});
}
} }
}; };
@@ -87,12 +183,33 @@ function TelegramLinkWidget() {
container.removeChild(container.firstChild); container.removeChild(container.firstChild);
} }
}; };
}, [botUsername, navigate, showToast, t, queryClient]); }, [isOIDC, botUsername, navigate, showToast, t, queryClient]);
if (!botUsername) { if (!botUsername && !isOIDC) {
return null; return null;
} }
if (isOIDC) {
return (
<Button
variant="primary"
size="sm"
disabled={oidcLoading || !scriptLoaded}
loading={oidcLoading}
onClick={() => {
setOidcLoading(true);
if (window.Telegram?.Login) {
window.Telegram.Login.open();
} else {
setOidcLoading(false);
}
}}
>
{t('profile.accounts.link')}
</Button>
);
}
return <div ref={containerRef} className="flex items-center" />; return <div ref={containerRef} className="flex items-center" />;
} }