mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: add OAuth 2.0 login UI (Google, Yandex, Discord, VK)
- Add OAuthProvider type and extend User.auth_type union - Add OAuth API methods (providers, authorize, callback) - Add loginWithOAuth to auth store - Create OAuthCallback page with state validation - Create OAuthProviderIcon component with brand SVGs - Add OAuth buttons to Login page between Telegram and Email - Add OAuth callback route to App.tsx - Add translations for ru, en, zh, fa
This commit is contained in:
@@ -18,6 +18,7 @@ import TelegramRedirect from './pages/TelegramRedirect';
|
|||||||
import DeepLinkRedirect from './pages/DeepLinkRedirect';
|
import DeepLinkRedirect from './pages/DeepLinkRedirect';
|
||||||
import VerifyEmail from './pages/VerifyEmail';
|
import VerifyEmail from './pages/VerifyEmail';
|
||||||
import ResetPassword from './pages/ResetPassword';
|
import ResetPassword from './pages/ResetPassword';
|
||||||
|
import OAuthCallback from './pages/OAuthCallback';
|
||||||
|
|
||||||
// User pages - lazy load
|
// User pages - lazy load
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
@@ -147,6 +148,7 @@ function App() {
|
|||||||
<Route path="/tg" element={<TelegramRedirect />} />
|
<Route path="/tg" element={<TelegramRedirect />} />
|
||||||
<Route path="/connect" element={<DeepLinkRedirect />} />
|
<Route path="/connect" element={<DeepLinkRedirect />} />
|
||||||
<Route path="/add" element={<DeepLinkRedirect />} />
|
<Route path="/add" element={<DeepLinkRedirect />} />
|
||||||
|
<Route path="/auth/oauth/callback" element={<OAuthCallback />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types';
|
import type { AuthResponse, OAuthProvider, RegisterResponse, TokenResponse, User } from '../types';
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
// Telegram WebApp authentication
|
// Telegram WebApp authentication
|
||||||
@@ -124,4 +124,31 @@ export const authApi = {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// OAuth: get enabled providers
|
||||||
|
getOAuthProviders: async (): Promise<{ providers: OAuthProvider[] }> => {
|
||||||
|
const response = await apiClient.get<{ providers: OAuthProvider[] }>(
|
||||||
|
'/cabinet/auth/oauth/providers',
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// OAuth: get authorization URL
|
||||||
|
getOAuthAuthorizeUrl: async (
|
||||||
|
provider: string,
|
||||||
|
): Promise<{ authorize_url: string; state: string }> => {
|
||||||
|
const response = await apiClient.get<{ authorize_url: string; state: string }>(
|
||||||
|
`/cabinet/auth/oauth/${provider}/authorize`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// OAuth: callback (exchange code for tokens)
|
||||||
|
oauthCallback: async (provider: string, code: string, state: string): Promise<AuthResponse> => {
|
||||||
|
const response = await apiClient.post<AuthResponse>(
|
||||||
|
`/cabinet/auth/oauth/${provider}/callback`,
|
||||||
|
{ code, state },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const AUTH_ENDPOINTS = [
|
|||||||
'/cabinet/auth/refresh',
|
'/cabinet/auth/refresh',
|
||||||
'/cabinet/auth/password/forgot',
|
'/cabinet/auth/password/forgot',
|
||||||
'/cabinet/auth/password/reset',
|
'/cabinet/auth/password/reset',
|
||||||
|
'/cabinet/auth/oauth/',
|
||||||
];
|
];
|
||||||
|
|
||||||
function isAuthEndpoint(url: string | undefined): boolean {
|
function isAuthEndpoint(url: string | undefined): boolean {
|
||||||
|
|||||||
74
src/components/OAuthProviderIcon.tsx
Normal file
74
src/components/OAuthProviderIcon.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
interface OAuthProviderIconProps {
|
||||||
|
provider: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OAuthProviderIcon({
|
||||||
|
provider,
|
||||||
|
className = 'h-5 w-5',
|
||||||
|
}: OAuthProviderIconProps) {
|
||||||
|
switch (provider) {
|
||||||
|
case 'google':
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'yandex':
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12z"
|
||||||
|
fill="#FC3F1D"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M13.63 7.56c-.7 0-1.2.18-1.6.52-.4.34-.6.85-.6 1.5 0 .5.13.92.38 1.26.25.34.68.73 1.29 1.16l.55.4-2.45 5.1H9.6l2.12-4.32c-.83-.57-1.43-1.12-1.8-1.66-.37-.54-.56-1.2-.56-1.96 0-1.03.35-1.85 1.04-2.47.7-.62 1.65-.93 2.87-.93H15.4V17.5h-1.77V7.56z"
|
||||||
|
fill="#fff"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'discord':
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028 14.09 14.09 0 001.226-1.994.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||||
|
fill="#5865F2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'vk':
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z"
|
||||||
|
fill="#0077FF"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12.76 16.42h.86s.26-.03.39-.17c.12-.13.12-.37.12-.37s-.02-1.14.51-1.31c.53-.16 1.2 1.08 1.92 1.56.54.36.96.28.96.28l1.93-.03s1.01-.06.53-.84c-.04-.06-.28-.58-1.45-1.64-1.22-1.1-1.06-.93.41-2.84.9-1.16 1.26-1.87 1.15-2.17-.1-.29-.76-.21-.76-.21l-2.17.01s-.16-.02-.28.05c-.12.07-.19.23-.19.23s-.35.93-.81 1.72c-.98 1.67-1.37 1.76-1.53 1.65-.37-.24-.28-1-.28-1.53 0-1.66.25-2.35-.49-2.53-.25-.06-.43-.1-1.06-.1-.81-.01-1.5 0-1.89.19-.26.13-.46.42-.34.44.15.02.5.09.68.34.24.32.23 1.04.23 1.04s.14 1.96-.32 2.2c-.31.17-.74-.17-1.66-1.7-.47-.78-.83-1.65-.83-1.65s-.07-.17-.19-.26c-.15-.11-.36-.14-.36-.14l-2.06.01s-.31.01-.42.14c-.1.12-.01.36-.01.36s1.63 3.81 3.47 5.74c1.69 1.77 3.61 1.65 3.61 1.65z"
|
||||||
|
fill="#fff"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -187,7 +187,13 @@
|
|||||||
"forgotPasswordHint": "Enter your email to receive a reset link",
|
"forgotPasswordHint": "Enter your email to receive a reset link",
|
||||||
"loginSuccess": "Login successful",
|
"loginSuccess": "Login successful",
|
||||||
"passwordResetSent": "Password reset link sent to your email",
|
"passwordResetSent": "Password reset link sent to your email",
|
||||||
"sendResetLink": "Send reset link"
|
"sendResetLink": "Send reset link",
|
||||||
|
"continueWithGoogle": "Continue with Google",
|
||||||
|
"continueWithYandex": "Continue with Yandex",
|
||||||
|
"continueWithDiscord": "Continue with Discord",
|
||||||
|
"continueWithVk": "Continue with VK",
|
||||||
|
"oauthError": "Authorization was denied or failed",
|
||||||
|
"oauthExpired": "OAuth session expired. Please try again."
|
||||||
},
|
},
|
||||||
"emailVerification": {
|
"emailVerification": {
|
||||||
"title": "Email Verification",
|
"title": "Email Verification",
|
||||||
|
|||||||
@@ -181,7 +181,13 @@
|
|||||||
"passwordTooShort": "رمز عبور باید حداقل ۸ کاراکتر باشد",
|
"passwordTooShort": "رمز عبور باید حداقل ۸ کاراکتر باشد",
|
||||||
"sendResetLink": "ارسال لینک بازنشانی",
|
"sendResetLink": "ارسال لینک بازنشانی",
|
||||||
"tooManyAttempts": "تلاشهای زیاد. لطفاً بعداً امتحان کنید",
|
"tooManyAttempts": "تلاشهای زیاد. لطفاً بعداً امتحان کنید",
|
||||||
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد"
|
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد",
|
||||||
|
"continueWithGoogle": "ادامه با Google",
|
||||||
|
"continueWithYandex": "ادامه با Yandex",
|
||||||
|
"continueWithDiscord": "ادامه با Discord",
|
||||||
|
"continueWithVk": "ادامه با VK",
|
||||||
|
"oauthError": "مجوز رد شد یا ناموفق بود",
|
||||||
|
"oauthExpired": "نشست OAuth منقضی شده است. لطفاً دوباره امتحان کنید."
|
||||||
},
|
},
|
||||||
"emailVerification": {
|
"emailVerification": {
|
||||||
"title": "تایید ایمیل",
|
"title": "تایید ایمیل",
|
||||||
|
|||||||
@@ -190,7 +190,13 @@
|
|||||||
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
|
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
|
||||||
"backToLogin": "Вернуться ко входу",
|
"backToLogin": "Вернуться ко входу",
|
||||||
"emailNotVerified": "Сначала подтвердите email",
|
"emailNotVerified": "Сначала подтвердите email",
|
||||||
"loginSuccess": "Авторизация успешна"
|
"loginSuccess": "Авторизация успешна",
|
||||||
|
"continueWithGoogle": "Продолжить через Google",
|
||||||
|
"continueWithYandex": "Продолжить через Яндекс",
|
||||||
|
"continueWithDiscord": "Продолжить через Discord",
|
||||||
|
"continueWithVk": "Продолжить через VK",
|
||||||
|
"oauthError": "Авторизация отклонена или не удалась",
|
||||||
|
"oauthExpired": "Сессия OAuth истекла. Попробуйте снова."
|
||||||
},
|
},
|
||||||
"emailVerification": {
|
"emailVerification": {
|
||||||
"title": "Подтверждение email",
|
"title": "Подтверждение email",
|
||||||
|
|||||||
@@ -181,7 +181,13 @@
|
|||||||
"passwordTooShort": "密码至少需要8个字符",
|
"passwordTooShort": "密码至少需要8个字符",
|
||||||
"sendResetLink": "发送重置链接",
|
"sendResetLink": "发送重置链接",
|
||||||
"tooManyAttempts": "尝试次数过多,请稍后再试",
|
"tooManyAttempts": "尝试次数过多,请稍后再试",
|
||||||
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱"
|
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱",
|
||||||
|
"continueWithGoogle": "通过 Google 继续",
|
||||||
|
"continueWithYandex": "通过 Yandex 继续",
|
||||||
|
"continueWithDiscord": "通过 Discord 继续",
|
||||||
|
"continueWithVk": "通过 VK 继续",
|
||||||
|
"oauthError": "授权被拒绝或失败",
|
||||||
|
"oauthExpired": "OAuth 会话已过期。请重试。"
|
||||||
},
|
},
|
||||||
"emailVerification": {
|
"emailVerification": {
|
||||||
"title": "邮箱验证",
|
"title": "邮箱验证",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { getAndClearReturnUrl } from '../utils/token';
|
|||||||
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
|
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
import TelegramLoginButton from '../components/TelegramLoginButton';
|
import TelegramLoginButton from '../components/TelegramLoginButton';
|
||||||
|
import OAuthProviderIcon from '../components/OAuthProviderIcon';
|
||||||
|
import { saveOAuthState } from './OAuthCallback';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -92,6 +94,29 @@ export default function Login() {
|
|||||||
});
|
});
|
||||||
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true;
|
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true;
|
||||||
|
|
||||||
|
// Fetch enabled OAuth providers
|
||||||
|
const { data: oauthData } = useQuery({
|
||||||
|
queryKey: ['oauth-providers'],
|
||||||
|
queryFn: authApi.getOAuthProviders,
|
||||||
|
staleTime: 60000,
|
||||||
|
});
|
||||||
|
const oauthProviders = oauthData?.providers ?? [];
|
||||||
|
|
||||||
|
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleOAuthLogin = async (provider: string) => {
|
||||||
|
setError('');
|
||||||
|
setOauthLoading(provider);
|
||||||
|
try {
|
||||||
|
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
|
||||||
|
saveOAuthState(state, provider);
|
||||||
|
window.location.href = authorize_url;
|
||||||
|
} catch {
|
||||||
|
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||||
|
setOauthLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
||||||
|
|
||||||
// If email auth is disabled but user came with ref param, redirect to bot
|
// If email auth is disabled but user came with ref param, redirect to bot
|
||||||
@@ -424,6 +449,38 @@ export default function Login() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* OAuth providers section */}
|
||||||
|
{oauthProviders.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="my-6 flex items-center gap-3">
|
||||||
|
<div className="h-px flex-1 bg-dark-700" />
|
||||||
|
<span className="text-xs text-dark-500">{t('auth.or', 'or')}</span>
|
||||||
|
<div className="h-px flex-1 bg-dark-700" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{oauthProviders.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOAuthLogin(provider.name)}
|
||||||
|
disabled={oauthLoading !== null}
|
||||||
|
className="hover:bg-dark-750 flex w-full items-center justify-center gap-3 rounded-xl border border-dark-700 bg-dark-800 px-4 py-3 text-sm font-medium text-dark-100 transition-colors hover:border-dark-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{oauthLoading === provider.name ? (
|
||||||
|
<span className="h-5 w-5 animate-spin rounded-full border-2 border-dark-400 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<OAuthProviderIcon provider={provider.name} className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
{t(
|
||||||
|
`auth.continueWith${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`,
|
||||||
|
`Continue with ${provider.display_name}`,
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Email auth section - only when enabled */}
|
{/* Email auth section - only when enabled */}
|
||||||
{isEmailAuthEnabled && (
|
{isEmailAuthEnabled && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
119
src/pages/OAuthCallback.tsx
Normal file
119
src/pages/OAuthCallback.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
|
// SessionStorage helpers for OAuth state
|
||||||
|
const OAUTH_STATE_KEY = 'oauth_state';
|
||||||
|
const OAUTH_PROVIDER_KEY = 'oauth_provider';
|
||||||
|
|
||||||
|
export function saveOAuthState(state: string, provider: string): void {
|
||||||
|
sessionStorage.setItem(OAUTH_STATE_KEY, state);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OAuthCallback() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const { loginWithOAuth, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticate = async () => {
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
const urlState = searchParams.get('state');
|
||||||
|
|
||||||
|
if (!code || !urlState) {
|
||||||
|
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get saved state from sessionStorage
|
||||||
|
const saved = getAndClearOAuthState();
|
||||||
|
if (!saved) {
|
||||||
|
setError(t('auth.oauthExpired', 'OAuth session expired. Please try again.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate state match
|
||||||
|
if (saved.state !== urlState) {
|
||||||
|
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loginWithOAuth(saved.provider, code, urlState);
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } };
|
||||||
|
setError(
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
t('auth.oauthError', 'Authorization was denied or failed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
authenticate();
|
||||||
|
}, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
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" />
|
||||||
|
<div className="relative w-full max-w-md text-center">
|
||||||
|
<div className="card">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-error-500/20">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-error-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</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>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/login', { replace: true })}
|
||||||
|
className="btn-primary w-full"
|
||||||
|
>
|
||||||
|
{t('auth.backToLogin', 'Back to login')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
||||||
|
<div className="relative text-center">
|
||||||
|
<div className="mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
<h2 className="text-lg font-semibold text-dark-50">{t('auth.authenticating')}</h2>
|
||||||
|
<p className="mt-2 text-sm text-dark-400">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ interface AuthState {
|
|||||||
loginWithTelegram: (initData: string) => Promise<void>;
|
loginWithTelegram: (initData: string) => Promise<void>;
|
||||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
||||||
loginWithEmail: (email: string, password: string) => Promise<void>;
|
loginWithEmail: (email: string, password: string) => Promise<void>;
|
||||||
|
loginWithOAuth: (provider: string, code: string, state: string) => Promise<void>;
|
||||||
registerWithEmail: (
|
registerWithEmail: (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
@@ -267,6 +268,18 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loginWithOAuth: async (provider, code, state) => {
|
||||||
|
const response = await authApi.oauthCallback(provider, code, state);
|
||||||
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
|
set({
|
||||||
|
accessToken: response.access_token,
|
||||||
|
refreshToken: response.refresh_token,
|
||||||
|
user: response.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
await get().checkAdminStatus();
|
||||||
|
},
|
||||||
|
|
||||||
registerWithEmail: async (email, password, firstName, referralCode) => {
|
registerWithEmail: async (email, password, firstName, referralCode) => {
|
||||||
// Registration now returns message, not tokens
|
// Registration now returns message, not tokens
|
||||||
// User must verify email before they can login
|
// User must verify email before they can login
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ export interface User {
|
|||||||
referral_code: string | null;
|
referral_code: string | null;
|
||||||
language: string;
|
language: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
auth_type: 'telegram' | 'email'; // Тип аутентификации
|
auth_type: 'telegram' | 'email' | 'google' | 'yandex' | 'discord' | 'vk'; // Тип аутентификации
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth types
|
||||||
|
export interface OAuthProvider {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth types
|
// Auth types
|
||||||
|
|||||||
Reference in New Issue
Block a user