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 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() {
|
||||
<Route path="/tg" element={<TelegramRedirect />} />
|
||||
<Route path="/connect" element={<DeepLinkRedirect />} />
|
||||
<Route path="/add" element={<DeepLinkRedirect />} />
|
||||
<Route path="/auth/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
|
||||
|
||||
@@ -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<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/password/forgot',
|
||||
'/cabinet/auth/password/reset',
|
||||
'/cabinet/auth/oauth/',
|
||||
];
|
||||
|
||||
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",
|
||||
"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",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"passwordTooShort": "رمز عبور باید حداقل ۸ کاراکتر باشد",
|
||||
"sendResetLink": "ارسال لینک بازنشانی",
|
||||
"tooManyAttempts": "تلاشهای زیاد. لطفاً بعداً امتحان کنید",
|
||||
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد"
|
||||
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد",
|
||||
"continueWithGoogle": "ادامه با Google",
|
||||
"continueWithYandex": "ادامه با Yandex",
|
||||
"continueWithDiscord": "ادامه با Discord",
|
||||
"continueWithVk": "ادامه با VK",
|
||||
"oauthError": "مجوز رد شد یا ناموفق بود",
|
||||
"oauthExpired": "نشست OAuth منقضی شده است. لطفاً دوباره امتحان کنید."
|
||||
},
|
||||
"emailVerification": {
|
||||
"title": "تایید ایمیل",
|
||||
|
||||
@@ -190,7 +190,13 @@
|
||||
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
|
||||
"backToLogin": "Вернуться ко входу",
|
||||
"emailNotVerified": "Сначала подтвердите email",
|
||||
"loginSuccess": "Авторизация успешна"
|
||||
"loginSuccess": "Авторизация успешна",
|
||||
"continueWithGoogle": "Продолжить через Google",
|
||||
"continueWithYandex": "Продолжить через Яндекс",
|
||||
"continueWithDiscord": "Продолжить через Discord",
|
||||
"continueWithVk": "Продолжить через VK",
|
||||
"oauthError": "Авторизация отклонена или не удалась",
|
||||
"oauthExpired": "Сессия OAuth истекла. Попробуйте снова."
|
||||
},
|
||||
"emailVerification": {
|
||||
"title": "Подтверждение email",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"passwordTooShort": "密码至少需要8个字符",
|
||||
"sendResetLink": "发送重置链接",
|
||||
"tooManyAttempts": "尝试次数过多,请稍后再试",
|
||||
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱"
|
||||
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱",
|
||||
"continueWithGoogle": "通过 Google 继续",
|
||||
"continueWithYandex": "通过 Yandex 继续",
|
||||
"continueWithDiscord": "通过 Discord 继续",
|
||||
"continueWithVk": "通过 VK 继续",
|
||||
"oauthError": "授权被拒绝或失败",
|
||||
"oauthExpired": "OAuth 会话已过期。请重试。"
|
||||
},
|
||||
"emailVerification": {
|
||||
"title": "邮箱验证",
|
||||
|
||||
@@ -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<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 || '';
|
||||
|
||||
// If email auth is disabled but user came with ref param, redirect to bot
|
||||
@@ -424,6 +449,38 @@ export default function Login() {
|
||||
)}
|
||||
</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 */}
|
||||
{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>;
|
||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
||||
loginWithEmail: (email: string, password: string) => Promise<void>;
|
||||
loginWithOAuth: (provider: string, code: string, state: string) => Promise<void>;
|
||||
registerWithEmail: (
|
||||
email: string,
|
||||
password: string,
|
||||
@@ -267,6 +268,18 @@ export const useAuthStore = create<AuthState>()(
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user