mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +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,
|
MergeResponse,
|
||||||
OAuthProvider,
|
OAuthProvider,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
|
ServerCompleteResponse,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
User,
|
User,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
@@ -260,8 +261,8 @@ export const authApi = {
|
|||||||
code: string,
|
code: string,
|
||||||
state: string,
|
state: string,
|
||||||
deviceId?: string,
|
deviceId?: string,
|
||||||
): Promise<LinkCallbackResponse & { provider?: string }> => {
|
): Promise<ServerCompleteResponse> => {
|
||||||
const response = await apiClient.post<LinkCallbackResponse & { provider?: string }>(
|
const response = await apiClient.post<ServerCompleteResponse>(
|
||||||
'/cabinet/auth/account/link/server-complete',
|
'/cabinet/auth/account/link/server-complete',
|
||||||
{
|
{
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const AUTH_ENDPOINTS = [
|
|||||||
|
|
||||||
function isAuthEndpoint(url: string | undefined): boolean {
|
function isAuthEndpoint(url: string | undefined): boolean {
|
||||||
if (!url) return false;
|
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
|
// Request interceptor - add auth token with expiration check
|
||||||
@@ -214,15 +214,10 @@ apiClient.interceptors.response.use(
|
|||||||
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента
|
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента
|
||||||
const authEndpoints = [
|
|
||||||
'/cabinet/auth/email/login',
|
|
||||||
'/cabinet/auth/telegram',
|
|
||||||
'/cabinet/auth/telegram/widget',
|
|
||||||
];
|
|
||||||
const requestUrl = originalRequest.url || '';
|
const requestUrl = originalRequest.url || '';
|
||||||
const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint));
|
const isLoginEndpoint = isAuthEndpoint(requestUrl);
|
||||||
|
|
||||||
if (isAuthEndpoint) {
|
if (isLoginEndpoint) {
|
||||||
// Пробрасываем ошибку в компонент для показа сообщения пользователю
|
// Пробрасываем ошибку в компонент для показа сообщения пользователю
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3619,6 +3619,7 @@
|
|||||||
"returnToTelegram": "Return to Telegram to continue",
|
"returnToTelegram": "Return to Telegram to continue",
|
||||||
"openTelegram": "Open Telegram",
|
"openTelegram": "Open Telegram",
|
||||||
"continueInBrowser": "Continue authorization in the opened browser",
|
"continueInBrowser": "Continue authorization in the opened browser",
|
||||||
|
"pollingTimeout": "Authorization check timed out. Refresh the page if you completed linking.",
|
||||||
"providers": {
|
"providers": {
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
|||||||
@@ -3055,6 +3055,7 @@
|
|||||||
"returnToTelegram": "برای ادامه به تلگرام بازگردید",
|
"returnToTelegram": "برای ادامه به تلگرام بازگردید",
|
||||||
"openTelegram": "باز کردن تلگرام",
|
"openTelegram": "باز کردن تلگرام",
|
||||||
"continueInBrowser": "ادامه احراز هویت در مرورگر باز شده",
|
"continueInBrowser": "ادامه احراز هویت در مرورگر باز شده",
|
||||||
|
"pollingTimeout": "بررسی احراز هویت منقضی شد. اگر اتصال را تکمیل کردهاید صفحه را بارگذاری مجدد کنید.",
|
||||||
"providers": {
|
"providers": {
|
||||||
"telegram": "تلگرام",
|
"telegram": "تلگرام",
|
||||||
"email": "ایمیل",
|
"email": "ایمیل",
|
||||||
|
|||||||
@@ -4179,6 +4179,7 @@
|
|||||||
"returnToTelegram": "Вернитесь в Telegram, чтобы продолжить",
|
"returnToTelegram": "Вернитесь в Telegram, чтобы продолжить",
|
||||||
"openTelegram": "Открыть Telegram",
|
"openTelegram": "Открыть Telegram",
|
||||||
"continueInBrowser": "Продолжите авторизацию в открывшемся браузере",
|
"continueInBrowser": "Продолжите авторизацию в открывшемся браузере",
|
||||||
|
"pollingTimeout": "Проверка авторизации истекла. Обновите страницу, если привязка завершена.",
|
||||||
"providers": {
|
"providers": {
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
|||||||
@@ -3054,6 +3054,7 @@
|
|||||||
"returnToTelegram": "返回 Telegram 继续操作",
|
"returnToTelegram": "返回 Telegram 继续操作",
|
||||||
"openTelegram": "打开 Telegram",
|
"openTelegram": "打开 Telegram",
|
||||||
"continueInBrowser": "请在打开的浏览器中继续授权",
|
"continueInBrowser": "请在打开的浏览器中继续授权",
|
||||||
|
"pollingTimeout": "授权检查超时。如果您已完成关联,请刷新页面。",
|
||||||
"providers": {
|
"providers": {
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Card } from '@/components/data-display/Card';
|
|||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||||
import ProviderIcon from '../components/ProviderIcon';
|
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 { getTelegramInitData } from '../hooks/useTelegramSDK';
|
||||||
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
|
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
|
||||||
import type { LinkedProvider } from '../types';
|
import type { LinkedProvider } from '../types';
|
||||||
@@ -98,7 +98,7 @@ export default function ConnectedAccounts() {
|
|||||||
const [confirmingUnlink, setConfirmingUnlink] = useState<string | null>(null);
|
const [confirmingUnlink, setConfirmingUnlink] = useState<string | null>(null);
|
||||||
const [linkingProvider, setLinkingProvider] = useState<string | null>(null);
|
const [linkingProvider, setLinkingProvider] = useState<string | null>(null);
|
||||||
const [waitingExternalLink, setWaitingExternalLink] = useState(false);
|
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 blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
const inTelegram = useIsTelegram();
|
const inTelegram = useIsTelegram();
|
||||||
@@ -118,21 +118,26 @@ export default function ConnectedAccounts() {
|
|||||||
refetchInterval: waitingExternalLink ? 5000 : false,
|
refetchInterval: waitingExternalLink ? 5000 : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stop polling after 90 seconds
|
// Stop polling after 90 seconds with timeout feedback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!waitingExternalLink) return;
|
if (!waitingExternalLink) return;
|
||||||
const timeout = setTimeout(() => setWaitingExternalLink(false), 90_000);
|
const timeout = setTimeout(() => {
|
||||||
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) {
|
|
||||||
setWaitingExternalLink(false);
|
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') });
|
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
||||||
}
|
}
|
||||||
}, [data, waitingExternalLink, showToast, t]);
|
}, [data, waitingExternalLink, showToast, t]);
|
||||||
@@ -169,14 +174,28 @@ export default function ConnectedAccounts() {
|
|||||||
setLinkingProvider(provider);
|
setLinkingProvider(provider);
|
||||||
try {
|
try {
|
||||||
const { authorize_url, state } = await authApi.linkProviderInit(provider);
|
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) {
|
if (inTelegram) {
|
||||||
// Mini App: open in external browser to avoid WebView OAuth restrictions.
|
// Mini App: open in external browser to avoid WebView OAuth restrictions.
|
||||||
// The callback will use server-complete flow (auth via state token, no JWT).
|
// The callback will use server-complete flow (auth via state token, no JWT).
|
||||||
platform.openLink(authorize_url);
|
platform.openLink(authorize_url);
|
||||||
setLinkingProvider(null);
|
setLinkingProvider(null);
|
||||||
// Snapshot current linked count before polling starts
|
// Track which provider we're waiting to become linked
|
||||||
linkedCountBeforePolling.current = data?.providers.filter((p) => p.linked).length ?? 0;
|
pendingLinkProvider.current = provider;
|
||||||
// Start polling for linked providers (external browser has no way to notify Mini App)
|
// Start polling for linked providers (external browser has no way to notify Mini App)
|
||||||
setWaitingExternalLink(true);
|
setWaitingExternalLink(true);
|
||||||
showToast({
|
showToast({
|
||||||
@@ -190,10 +209,10 @@ export default function ConnectedAccounts() {
|
|||||||
sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider);
|
sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider);
|
||||||
window.location.href = authorize_url;
|
window.location.href = authorize_url;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
showToast({
|
showToast({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: t('profile.accounts.linkError'),
|
message: getErrorDetail(err) || t('profile.accounts.linkError'),
|
||||||
});
|
});
|
||||||
setLinkingProvider(null);
|
setLinkingProvider(null);
|
||||||
}
|
}
|
||||||
@@ -213,8 +232,8 @@ export default function ConnectedAccounts() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
|
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
|
||||||
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
showToast({ type: 'error', message: t('profile.accounts.linkError') });
|
showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError') });
|
||||||
} finally {
|
} finally {
|
||||||
setLinkingProvider(null);
|
setLinkingProvider(null);
|
||||||
}
|
}
|
||||||
@@ -245,7 +264,7 @@ export default function ConnectedAccounts() {
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={linkingProvider !== null}
|
disabled={linkingProvider !== null || waitingExternalLink}
|
||||||
loading={linkingProvider === 'telegram'}
|
loading={linkingProvider === 'telegram'}
|
||||||
onClick={() => handleLink('telegram')}
|
onClick={() => handleLink('telegram')}
|
||||||
>
|
>
|
||||||
@@ -262,7 +281,7 @@ export default function ConnectedAccounts() {
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={linkingProvider !== null}
|
disabled={linkingProvider !== null || waitingExternalLink}
|
||||||
loading={linkingProvider === provider.provider}
|
loading={linkingProvider === provider.provider}
|
||||||
onClick={() => handleLink(provider.provider)}
|
onClick={() => handleLink(provider.provider)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { LINK_TELEGRAM_STATE_KEY } from './ConnectedAccounts';
|
import { LINK_TELEGRAM_STATE_KEY } from './ConnectedAccounts';
|
||||||
|
import { getErrorDetail } from './OAuthCallback';
|
||||||
|
|
||||||
export default function LinkTelegramCallback() {
|
export default function LinkTelegramCallback() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -69,8 +70,11 @@ export default function LinkTelegramCallback() {
|
|||||||
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
||||||
navigate('/profile/accounts', { replace: true });
|
navigate('/profile/accounts', { replace: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
showToast({ type: 'error', message: t('profile.accounts.linkError') });
|
showToast({
|
||||||
|
type: 'error',
|
||||||
|
message: getErrorDetail(err) || t('profile.accounts.linkError'),
|
||||||
|
});
|
||||||
navigate('/profile/accounts', { replace: true });
|
navigate('/profile/accounts', { replace: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import { authApi } from '../api/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)
|
// SessionStorage keys for OAuth LINK state (shared with ConnectedAccounts)
|
||||||
export const LINK_OAUTH_STATE_KEY = 'link_oauth_state';
|
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);
|
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). */
|
/** Read link OAuth state without clearing (cleared only after successful match). */
|
||||||
function peekLinkOAuthState(): { state: string; provider: string } | null {
|
function peekLinkOAuthState(): { state: string; provider: string } | null {
|
||||||
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
|
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
|
||||||
@@ -42,12 +33,15 @@ function clearLinkOAuthState(): void {
|
|||||||
|
|
||||||
type CallbackMode = 'login' | 'link-browser' | 'link-server';
|
type CallbackMode = 'login' | 'link-browser' | 'link-server';
|
||||||
|
|
||||||
type ServerLinkResult = LinkCallbackResponse & { provider?: string };
|
export function getErrorDetail(err: unknown): string | null {
|
||||||
|
|
||||||
function getErrorDetail(err: unknown): string | null {
|
|
||||||
if (err && typeof err === 'object' && 'response' in err) {
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
const resp = (err as { response?: { data?: { detail?: string } } }).response;
|
const resp = (err as { response?: { data?: { detail?: unknown } } }).response;
|
||||||
if (resp?.data?.detail) return resp.data.detail;
|
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;
|
if (err instanceof Error) return err.message;
|
||||||
return null;
|
return null;
|
||||||
@@ -59,7 +53,9 @@ export default function OAuthCallback() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [errorMode, setErrorMode] = useState<CallbackMode>('login');
|
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 loginWithOAuth = useAuthStore((state) => state.loginWithOAuth);
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const hasRun = useRef(false);
|
const hasRun = useRef(false);
|
||||||
@@ -100,15 +96,22 @@ export default function OAuthCallback() {
|
|||||||
provider = linkSaved.provider;
|
provider = linkSaved.provider;
|
||||||
state = linkSaved.state;
|
state = linkSaved.state;
|
||||||
} else {
|
} else {
|
||||||
const loginSaved = getAndClearOAuthState();
|
// Peek at login state first; only clear if it matches URL state
|
||||||
if (loginSaved && loginSaved.state === urlState) {
|
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';
|
mode = 'login';
|
||||||
provider = loginSaved.provider;
|
provider = loginProvider;
|
||||||
state = loginSaved.state;
|
state = loginState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handle = async () => {
|
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) {
|
if (mode === 'link-browser' && provider && state) {
|
||||||
// Browser linking: user is authenticated, complete via JWT-protected endpoint
|
// Browser linking: user is authenticated, complete via JWT-protected endpoint
|
||||||
try {
|
try {
|
||||||
@@ -123,9 +126,9 @@ export default function OAuthCallback() {
|
|||||||
} else {
|
} else {
|
||||||
navigate('/profile/accounts', { replace: true });
|
navigate('/profile/accounts', { replace: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
setErrorMode('link-browser');
|
setErrorMode('link-browser');
|
||||||
setError(t('profile.accounts.linkError'));
|
setError(getErrorDetail(err) || t('profile.accounts.linkError'));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,16 +152,13 @@ export default function OAuthCallback() {
|
|||||||
// mode === 'link-server': No sessionStorage state found.
|
// mode === 'link-server': No sessionStorage state found.
|
||||||
// This happens when OAuth was opened in external browser from Mini App.
|
// This happens when OAuth was opened in external browser from Mini App.
|
||||||
// Complete linking via state-token-authenticated server endpoint.
|
// Complete linking via state-token-authenticated server endpoint.
|
||||||
// Clear sensitive data from URL immediately.
|
|
||||||
window.history.replaceState({}, '', '/auth/oauth/callback');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Provider is resolved server-side from the state token in Redis.
|
// Provider is resolved server-side from the state token in Redis.
|
||||||
const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined);
|
const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined);
|
||||||
setServerLinkResult(response);
|
setServerCompleteResponse(response);
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
setErrorMode('link-server');
|
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
|
// Server-complete result: show success with "Return to Telegram" link
|
||||||
// (merge redirect is handled by the useEffect above)
|
// (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 botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
||||||
const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
|
const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
|
||||||
|
|
||||||
@@ -207,9 +211,34 @@ export default function OAuthCallback() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
const isServerMode = errorMode === 'link-server';
|
const isServerMode = errorMode === 'link-server';
|
||||||
|
const isLinkBrowserMode = errorMode === 'link-browser';
|
||||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
||||||
const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center px-4 py-8">
|
<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" />
|
<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>
|
</div>
|
||||||
<h2 className="mb-2 text-lg font-semibold text-dark-50">{t('auth.loginFailed')}</h2>
|
<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>
|
<p className="mb-6 text-sm text-dark-400">{error}</p>
|
||||||
{isServerMode && telegramLink ? (
|
{errorAction}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -655,6 +655,10 @@ export interface LinkCallbackResponse {
|
|||||||
merge_token: string | null;
|
merge_token: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerCompleteResponse extends LinkCallbackResponse {
|
||||||
|
provider: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Account Merge
|
// Account Merge
|
||||||
export interface MergeSubscriptionPreview {
|
export interface MergeSubscriptionPreview {
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user