feat: account merge flow — merge redirect, error handling, server-complete linking

- Добавлен merge_required + merge_token redirect в OAuthCallback и LinkTelegramCallback
- Server-complete OAuth linking для Mini App (внешний браузер)
- getErrorDetail: единый экстрактор ошибок из axios response
- LinkTelegramCallback: типизированный catch с getErrorDetail
- Удалён мёртвый экспорт getAndClearOAuthState
- CSRF валидация для Telegram и OAuth linking flows
- Типы ServerCompleteResponse, LinkCallbackResponse
This commit is contained in:
Fringg
2026-03-05 05:25:50 +03:00
parent 0d99ea0069
commit 2fc0759f89
10 changed files with 120 additions and 78 deletions

View File

@@ -7,6 +7,7 @@ import type {
MergeResponse,
OAuthProvider,
RegisterResponse,
ServerCompleteResponse,
TokenResponse,
User,
} from '../types';
@@ -260,8 +261,8 @@ export const authApi = {
code: string,
state: string,
deviceId?: string,
): Promise<LinkCallbackResponse & { provider?: string }> => {
const response = await apiClient.post<LinkCallbackResponse & { provider?: string }>(
): Promise<ServerCompleteResponse> => {
const response = await apiClient.post<ServerCompleteResponse>(
'/cabinet/auth/account/link/server-complete',
{
code,

View File

@@ -81,7 +81,7 @@ const AUTH_ENDPOINTS = [
function isAuthEndpoint(url: string | undefined): boolean {
if (!url) return false;
return AUTH_ENDPOINTS.some((endpoint) => url.includes(endpoint));
return AUTH_ENDPOINTS.some((endpoint) => url.startsWith(endpoint));
}
// Request interceptor - add auth token with expiration check
@@ -214,15 +214,10 @@ apiClient.interceptors.response.use(
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
if (error.response?.status === 401 && !originalRequest._retry) {
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента
const authEndpoints = [
'/cabinet/auth/email/login',
'/cabinet/auth/telegram',
'/cabinet/auth/telegram/widget',
];
const requestUrl = originalRequest.url || '';
const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint));
const isLoginEndpoint = isAuthEndpoint(requestUrl);
if (isAuthEndpoint) {
if (isLoginEndpoint) {
// Пробрасываем ошибку в компонент для показа сообщения пользователю
return Promise.reject(error);
}

View File

@@ -3619,6 +3619,7 @@
"returnToTelegram": "Return to Telegram to continue",
"openTelegram": "Open Telegram",
"continueInBrowser": "Continue authorization in the opened browser",
"pollingTimeout": "Authorization check timed out. Refresh the page if you completed linking.",
"providers": {
"telegram": "Telegram",
"email": "Email",

View File

@@ -3055,6 +3055,7 @@
"returnToTelegram": "برای ادامه به تلگرام بازگردید",
"openTelegram": "باز کردن تلگرام",
"continueInBrowser": "ادامه احراز هویت در مرورگر باز شده",
"pollingTimeout": "بررسی احراز هویت منقضی شد. اگر اتصال را تکمیل کرده‌اید صفحه را بارگذاری مجدد کنید.",
"providers": {
"telegram": "تلگرام",
"email": "ایمیل",

View File

@@ -4179,6 +4179,7 @@
"returnToTelegram": "Вернитесь в Telegram, чтобы продолжить",
"openTelegram": "Открыть Telegram",
"continueInBrowser": "Продолжите авторизацию в открывшемся браузере",
"pollingTimeout": "Проверка авторизации истекла. Обновите страницу, если привязка завершена.",
"providers": {
"telegram": "Telegram",
"email": "Email",

View File

@@ -3054,6 +3054,7 @@
"returnToTelegram": "返回 Telegram 继续操作",
"openTelegram": "打开 Telegram",
"continueInBrowser": "请在打开的浏览器中继续授权",
"pollingTimeout": "授权检查超时。如果您已完成关联,请刷新页面。",
"providers": {
"telegram": "Telegram",
"email": "邮箱",

View File

@@ -9,7 +9,7 @@ import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import ProviderIcon from '../components/ProviderIcon';
import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './OAuthCallback';
import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from './OAuthCallback';
import { getTelegramInitData } from '../hooks/useTelegramSDK';
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
import type { LinkedProvider } from '../types';
@@ -98,7 +98,7 @@ export default function ConnectedAccounts() {
const [confirmingUnlink, setConfirmingUnlink] = useState<string | null>(null);
const [linkingProvider, setLinkingProvider] = useState<string | null>(null);
const [waitingExternalLink, setWaitingExternalLink] = useState(false);
const linkedCountBeforePolling = useRef<number | null>(null);
const pendingLinkProvider = useRef<string | null>(null);
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const inTelegram = useIsTelegram();
@@ -118,21 +118,26 @@ export default function ConnectedAccounts() {
refetchInterval: waitingExternalLink ? 5000 : false,
});
// Stop polling after 90 seconds
// Stop polling after 90 seconds with timeout feedback
useEffect(() => {
if (!waitingExternalLink) return;
const timeout = setTimeout(() => setWaitingExternalLink(false), 90_000);
return () => clearTimeout(timeout);
}, [waitingExternalLink]);
// Detect successful external link: stop polling and show toast when linked count increases
useEffect(() => {
if (!waitingExternalLink || !data) return;
const currentLinked = data.providers.filter((p) => p.linked).length;
if (linkedCountBeforePolling.current === null) return;
if (currentLinked > linkedCountBeforePolling.current) {
const timeout = setTimeout(() => {
setWaitingExternalLink(false);
linkedCountBeforePolling.current = null;
pendingLinkProvider.current = null;
// Final refresh in case link succeeded during the last polling interval
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ type: 'warning', message: t('profile.accounts.pollingTimeout') });
}, 90_000);
return () => clearTimeout(timeout);
}, [waitingExternalLink, showToast, t, queryClient]);
// Detect successful external link: stop polling when the target provider becomes linked
useEffect(() => {
if (!waitingExternalLink || !data || !pendingLinkProvider.current) return;
const target = data.providers.find((p) => p.provider === pendingLinkProvider.current);
if (target?.linked) {
setWaitingExternalLink(false);
pendingLinkProvider.current = null;
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
}
}, [data, waitingExternalLink, showToast, t]);
@@ -169,14 +174,28 @@ export default function ConnectedAccounts() {
setLinkingProvider(provider);
try {
const { authorize_url, state } = await authApi.linkProviderInit(provider);
if (!authorize_url || !state) {
throw new Error('Invalid response from server');
}
// Validate redirect URL — only allow HTTPS to prevent open redirect
let parsed: URL;
try {
parsed = new URL(authorize_url);
} catch {
throw new Error('Invalid OAuth redirect URL');
}
if (parsed.protocol !== 'https:') {
throw new Error('Invalid OAuth redirect URL');
}
if (inTelegram) {
// Mini App: open in external browser to avoid WebView OAuth restrictions.
// The callback will use server-complete flow (auth via state token, no JWT).
platform.openLink(authorize_url);
setLinkingProvider(null);
// Snapshot current linked count before polling starts
linkedCountBeforePolling.current = data?.providers.filter((p) => p.linked).length ?? 0;
// Track which provider we're waiting to become linked
pendingLinkProvider.current = provider;
// Start polling for linked providers (external browser has no way to notify Mini App)
setWaitingExternalLink(true);
showToast({
@@ -190,10 +209,10 @@ export default function ConnectedAccounts() {
sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider);
window.location.href = authorize_url;
}
} catch {
} catch (err: unknown) {
showToast({
type: 'error',
message: t('profile.accounts.linkError'),
message: getErrorDetail(err) || t('profile.accounts.linkError'),
});
setLinkingProvider(null);
}
@@ -213,8 +232,8 @@ export default function ConnectedAccounts() {
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
}
} catch {
showToast({ type: 'error', message: t('profile.accounts.linkError') });
} catch (err: unknown) {
showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError') });
} finally {
setLinkingProvider(null);
}
@@ -245,7 +264,7 @@ export default function ConnectedAccounts() {
<Button
variant="primary"
size="sm"
disabled={linkingProvider !== null}
disabled={linkingProvider !== null || waitingExternalLink}
loading={linkingProvider === 'telegram'}
onClick={() => handleLink('telegram')}
>
@@ -262,7 +281,7 @@ export default function ConnectedAccounts() {
<Button
variant="primary"
size="sm"
disabled={linkingProvider !== null}
disabled={linkingProvider !== null || waitingExternalLink}
loading={linkingProvider === provider.provider}
onClick={() => handleLink(provider.provider)}
>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { authApi } from '../api/auth';
import { useToast } from '../components/Toast';
import { LINK_TELEGRAM_STATE_KEY } from './ConnectedAccounts';
import { getErrorDetail } from './OAuthCallback';
export default function LinkTelegramCallback() {
const { t } = useTranslation();
@@ -69,8 +70,11 @@ export default function LinkTelegramCallback() {
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
navigate('/profile/accounts', { replace: true });
}
} catch {
showToast({ type: 'error', message: t('profile.accounts.linkError') });
} catch (err: unknown) {
showToast({
type: 'error',
message: getErrorDetail(err) || t('profile.accounts.linkError'),
});
navigate('/profile/accounts', { replace: true });
}
};

View File

@@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
import { authApi } from '../api/auth';
import type { LinkCallbackResponse } from '../types';
import type { ServerCompleteResponse } from '../types';
// SessionStorage keys for OAuth LINK state (shared with ConnectedAccounts)
export const LINK_OAUTH_STATE_KEY = 'link_oauth_state';
@@ -18,15 +18,6 @@ export function saveOAuthState(state: string, provider: string): void {
sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider);
}
export function getAndClearOAuthState(): { state: string; provider: string } | null {
const state = sessionStorage.getItem(OAUTH_STATE_KEY);
const provider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
sessionStorage.removeItem(OAUTH_STATE_KEY);
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
if (!state || !provider) return null;
return { state, provider };
}
/** Read link OAuth state without clearing (cleared only after successful match). */
function peekLinkOAuthState(): { state: string; provider: string } | null {
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
@@ -42,12 +33,15 @@ function clearLinkOAuthState(): void {
type CallbackMode = 'login' | 'link-browser' | 'link-server';
type ServerLinkResult = LinkCallbackResponse & { provider?: string };
function getErrorDetail(err: unknown): string | null {
export function getErrorDetail(err: unknown): string | null {
if (err && typeof err === 'object' && 'response' in err) {
const resp = (err as { response?: { data?: { detail?: string } } }).response;
if (resp?.data?.detail) return resp.data.detail;
const resp = (err as { response?: { data?: { detail?: unknown } } }).response;
const detail = resp?.data?.detail;
if (typeof detail === 'string') return detail;
if (detail && typeof detail === 'object' && 'message' in detail) {
const msg = (detail as Record<string, unknown>).message;
if (typeof msg === 'string') return msg;
}
}
if (err instanceof Error) return err.message;
return null;
@@ -59,7 +53,9 @@ export default function OAuthCallback() {
const [searchParams] = useSearchParams();
const [error, setError] = useState('');
const [errorMode, setErrorMode] = useState<CallbackMode>('login');
const [serverLinkResult, setServerLinkResult] = useState<ServerLinkResult | null>(null);
const [serverLinkResult, setServerCompleteResponse] = useState<ServerCompleteResponse | null>(
null,
);
const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const hasRun = useRef(false);
@@ -100,15 +96,22 @@ export default function OAuthCallback() {
provider = linkSaved.provider;
state = linkSaved.state;
} else {
const loginSaved = getAndClearOAuthState();
if (loginSaved && loginSaved.state === urlState) {
// Peek at login state first; only clear if it matches URL state
const loginState = sessionStorage.getItem(OAUTH_STATE_KEY);
const loginProvider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
if (loginState && loginProvider && loginState === urlState) {
sessionStorage.removeItem(OAUTH_STATE_KEY);
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
mode = 'login';
provider = loginSaved.provider;
state = loginSaved.state;
provider = loginProvider;
state = loginState;
}
}
const handle = async () => {
// Clear sensitive OAuth params (code, state) from URL immediately for all modes
window.history.replaceState({}, '', '/auth/oauth/callback');
if (mode === 'link-browser' && provider && state) {
// Browser linking: user is authenticated, complete via JWT-protected endpoint
try {
@@ -123,9 +126,9 @@ export default function OAuthCallback() {
} else {
navigate('/profile/accounts', { replace: true });
}
} catch {
} catch (err: unknown) {
setErrorMode('link-browser');
setError(t('profile.accounts.linkError'));
setError(getErrorDetail(err) || t('profile.accounts.linkError'));
}
return;
}
@@ -149,16 +152,13 @@ export default function OAuthCallback() {
// mode === 'link-server': No sessionStorage state found.
// This happens when OAuth was opened in external browser from Mini App.
// Complete linking via state-token-authenticated server endpoint.
// Clear sensitive data from URL immediately.
window.history.replaceState({}, '', '/auth/oauth/callback');
try {
// Provider is resolved server-side from the state token in Redis.
const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined);
setServerLinkResult(response);
} catch {
setServerCompleteResponse(response);
} catch (err: unknown) {
setErrorMode('link-server');
setError(t('profile.accounts.linkError'));
setError(getErrorDetail(err) || t('profile.accounts.linkError'));
}
};
@@ -167,7 +167,11 @@ export default function OAuthCallback() {
// Server-complete result: show success with "Return to Telegram" link
// (merge redirect is handled by the useEffect above)
if (serverLinkResult && !(serverLinkResult.merge_required && serverLinkResult.merge_token)) {
if (
serverLinkResult &&
serverLinkResult.success &&
!(serverLinkResult.merge_required && serverLinkResult.merge_token)
) {
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
@@ -207,9 +211,34 @@ export default function OAuthCallback() {
if (error) {
const isServerMode = errorMode === 'link-server';
const isLinkBrowserMode = errorMode === 'link-browser';
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
const errorAction =
isServerMode && telegramLink ? (
<a
href={telegramLink}
className="btn-primary inline-block w-full rounded-lg bg-accent-500 px-6 py-3 text-center font-medium text-dark-950 no-underline transition-colors hover:bg-accent-400"
>
{t('profile.accounts.openTelegram')}
</a>
) : isLinkBrowserMode ? (
<button
onClick={() => navigate('/profile/accounts', { replace: true })}
className="btn-primary w-full"
>
{t('profile.accounts.backToAccounts', 'Back to accounts')}
</button>
) : (
<button
onClick={() => navigate('/login', { replace: true })}
className="btn-primary w-full"
>
{t('auth.backToLogin', 'Back to login')}
</button>
);
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
@@ -232,21 +261,7 @@ export default function OAuthCallback() {
</div>
<h2 className="mb-2 text-lg font-semibold text-dark-50">{t('auth.loginFailed')}</h2>
<p className="mb-6 text-sm text-dark-400">{error}</p>
{isServerMode && telegramLink ? (
<a
href={telegramLink}
className="btn-primary inline-block w-full rounded-lg bg-accent-500 px-6 py-3 text-center font-medium text-dark-950 no-underline transition-colors hover:bg-accent-400"
>
{t('profile.accounts.openTelegram')}
</a>
) : (
<button
onClick={() => navigate('/login', { replace: true })}
className="btn-primary w-full"
>
{t('auth.backToLogin', 'Back to login')}
</button>
)}
{errorAction}
</div>
</div>
</div>

View File

@@ -655,6 +655,10 @@ export interface LinkCallbackResponse {
merge_token: string | null;
}
export interface ServerCompleteResponse extends LinkCallbackResponse {
provider: string;
}
// Account Merge
export interface MergeSubscriptionPreview {
status: string;