diff --git a/src/App.tsx b/src/App.tsx index 8d1ba28..58bdcaa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import TelegramRedirect from './pages/TelegramRedirect'; import DeepLinkRedirect from './pages/DeepLinkRedirect'; import VerifyEmail from './pages/VerifyEmail'; import ResetPassword from './pages/ResetPassword'; +import OAuthCallback from './pages/OAuthCallback'; // User pages - lazy load const Dashboard = lazy(() => import('./pages/Dashboard')); @@ -147,6 +148,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/api/auth.ts b/src/api/auth.ts index f5c7b9b..a9b0552 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,5 +1,5 @@ import apiClient from './client'; -import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types'; +import type { AuthResponse, OAuthProvider, RegisterResponse, TokenResponse, User } from '../types'; export const authApi = { // Telegram WebApp authentication @@ -124,4 +124,31 @@ export const authApi = { }); 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 => { + const response = await apiClient.post( + `/cabinet/auth/oauth/${provider}/callback`, + { code, state }, + ); + return response.data; + }, }; diff --git a/src/api/client.ts b/src/api/client.ts index 9dcdc85..046aa9d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -74,6 +74,7 @@ const AUTH_ENDPOINTS = [ '/cabinet/auth/refresh', '/cabinet/auth/password/forgot', '/cabinet/auth/password/reset', + '/cabinet/auth/oauth/', ]; function isAuthEndpoint(url: string | undefined): boolean { diff --git a/src/components/OAuthProviderIcon.tsx b/src/components/OAuthProviderIcon.tsx new file mode 100644 index 0000000..11eeff8 --- /dev/null +++ b/src/components/OAuthProviderIcon.tsx @@ -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 ( + + + + + + + ); + + case 'yandex': + return ( + + + + + ); + + case 'discord': + return ( + + + + ); + + case 'vk': + return ( + + + + + ); + + default: + return null; + } +} diff --git a/src/locales/en.json b/src/locales/en.json index 77acc04..aad704e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -187,7 +187,13 @@ "forgotPasswordHint": "Enter your email to receive a reset link", "loginSuccess": "Login successful", "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": { "title": "Email Verification", diff --git a/src/locales/fa.json b/src/locales/fa.json index fcce861..d315c08 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -181,7 +181,13 @@ "passwordTooShort": "رمز عبور باید حداقل ۸ کاراکتر باشد", "sendResetLink": "ارسال لینک بازنشانی", "tooManyAttempts": "تلاش‌های زیاد. لطفاً بعداً امتحان کنید", - "verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد" + "verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد", + "continueWithGoogle": "ادامه با Google", + "continueWithYandex": "ادامه با Yandex", + "continueWithDiscord": "ادامه با Discord", + "continueWithVk": "ادامه با VK", + "oauthError": "مجوز رد شد یا ناموفق بود", + "oauthExpired": "نشست OAuth منقضی شده است. لطفاً دوباره امتحان کنید." }, "emailVerification": { "title": "تایید ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index c6ef15f..2715923 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -190,7 +190,13 @@ "clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.", "backToLogin": "Вернуться ко входу", "emailNotVerified": "Сначала подтвердите email", - "loginSuccess": "Авторизация успешна" + "loginSuccess": "Авторизация успешна", + "continueWithGoogle": "Продолжить через Google", + "continueWithYandex": "Продолжить через Яндекс", + "continueWithDiscord": "Продолжить через Discord", + "continueWithVk": "Продолжить через VK", + "oauthError": "Авторизация отклонена или не удалась", + "oauthExpired": "Сессия OAuth истекла. Попробуйте снова." }, "emailVerification": { "title": "Подтверждение email", diff --git a/src/locales/zh.json b/src/locales/zh.json index 7520d66..5b9d533 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -181,7 +181,13 @@ "passwordTooShort": "密码至少需要8个字符", "sendResetLink": "发送重置链接", "tooManyAttempts": "尝试次数过多,请稍后再试", - "verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱" + "verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱", + "continueWithGoogle": "通过 Google 继续", + "continueWithYandex": "通过 Yandex 继续", + "continueWithDiscord": "通过 Discord 继续", + "continueWithVk": "通过 VK 继续", + "oauthError": "授权被拒绝或失败", + "oauthExpired": "OAuth 会话已过期。请重试。" }, "emailVerification": { "title": "邮箱验证", diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 76b9f3b..6f1a58f 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -18,6 +18,8 @@ import { getAndClearReturnUrl } from '../utils/token'; import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; import LanguageSwitcher from '../components/LanguageSwitcher'; import TelegramLoginButton from '../components/TelegramLoginButton'; +import OAuthProviderIcon from '../components/OAuthProviderIcon'; +import { saveOAuthState } from './OAuthCallback'; export default function Login() { const { t } = useTranslation(); @@ -92,6 +94,29 @@ export default function Login() { }); 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(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 || ''; // If email auth is disabled but user came with ref param, redirect to bot @@ -424,6 +449,38 @@ export default function Login() { )} + {/* OAuth providers section */} + {oauthProviders.length > 0 && ( + <> +
+
+ {t('auth.or', 'or')} +
+
+
+ {oauthProviders.map((provider) => ( + + ))} +
+ + )} + {/* Email auth section - only when enabled */} {isEmailAuthEnabled && ( <> diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx new file mode 100644 index 0000000..7078fc5 --- /dev/null +++ b/src/pages/OAuthCallback.tsx @@ -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 ( +
+
+
+
+
+ + + +
+

{t('auth.loginFailed')}

+

{error}

+ +
+
+
+ ); + } + + return ( +
+
+
+
+

{t('auth.authenticating')}

+

{t('common.loading')}

+
+
+ ); +} diff --git a/src/store/auth.ts b/src/store/auth.ts index 91b2fd0..56364bb 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -33,6 +33,7 @@ interface AuthState { loginWithTelegram: (initData: string) => Promise; loginWithTelegramWidget: (data: TelegramWidgetData) => Promise; loginWithEmail: (email: string, password: string) => Promise; + loginWithOAuth: (provider: string, code: string, state: string) => Promise; registerWithEmail: ( email: string, password: string, @@ -267,6 +268,18 @@ export const useAuthStore = create()( 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) => { // Registration now returns message, not tokens // User must verify email before they can login diff --git a/src/types/index.ts b/src/types/index.ts index 4d45348..23bc36e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,7 +12,13 @@ export interface User { referral_code: string | null; language: 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