mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3055,6 +3055,7 @@
|
||||
"returnToTelegram": "برای ادامه به تلگرام بازگردید",
|
||||
"openTelegram": "باز کردن تلگرام",
|
||||
"continueInBrowser": "ادامه احراز هویت در مرورگر باز شده",
|
||||
"pollingTimeout": "بررسی احراز هویت منقضی شد. اگر اتصال را تکمیل کردهاید صفحه را بارگذاری مجدد کنید.",
|
||||
"providers": {
|
||||
"telegram": "تلگرام",
|
||||
"email": "ایمیل",
|
||||
|
||||
@@ -4179,6 +4179,7 @@
|
||||
"returnToTelegram": "Вернитесь в Telegram, чтобы продолжить",
|
||||
"openTelegram": "Открыть Telegram",
|
||||
"continueInBrowser": "Продолжите авторизацию в открывшемся браузере",
|
||||
"pollingTimeout": "Проверка авторизации истекла. Обновите страницу, если привязка завершена.",
|
||||
"providers": {
|
||||
"telegram": "Telegram",
|
||||
"email": "Email",
|
||||
|
||||
@@ -3054,6 +3054,7 @@
|
||||
"returnToTelegram": "返回 Telegram 继续操作",
|
||||
"openTelegram": "打开 Telegram",
|
||||
"continueInBrowser": "请在打开的浏览器中继续授权",
|
||||
"pollingTimeout": "授权检查超时。如果您已完成关联,请刷新页面。",
|
||||
"providers": {
|
||||
"telegram": "Telegram",
|
||||
"email": "邮箱",
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user