From 3bee6c370a2b87294efdf0ac614b311f334930d9 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sat, 4 Apr 2026 14:21:31 +0700 Subject: [PATCH] Add Telegram Mini App login flow --- app/composables/useTelegramMiniApp.ts | 61 ++++++++++++ app/middleware/auth.global.ts | 17 +++- app/pages/login.vue | 98 ++++++++++++++++++- app/plugins/telegram-mini-app.client.ts | 9 ++ nuxt.config.ts | 1 + .../auth/telegram-mini-app/connect.post.ts | 27 +++++ .../auth/telegram-mini-app/session.post.ts | 27 +++++ 7 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 app/composables/useTelegramMiniApp.ts create mode 100644 app/plugins/telegram-mini-app.client.ts create mode 100644 server/api/auth/telegram-mini-app/connect.post.ts create mode 100644 server/api/auth/telegram-mini-app/session.post.ts diff --git a/app/composables/useTelegramMiniApp.ts b/app/composables/useTelegramMiniApp.ts new file mode 100644 index 0000000..f5fcba9 --- /dev/null +++ b/app/composables/useTelegramMiniApp.ts @@ -0,0 +1,61 @@ +type TelegramMiniAppUser = { + id: number | string; + first_name?: string; + last_name?: string; + username?: string; + language_code?: string; + photo_url?: string; +}; + +type TelegramWebApp = { + initData?: string; + initDataUnsafe?: { + user?: TelegramMiniAppUser; + start_param?: string; + }; + ready?: () => void; + expand?: () => void; +}; + +declare global { + interface Window { + Telegram?: { + WebApp?: TelegramWebApp; + }; + } +} + +function buildDisplayName(user: TelegramMiniAppUser | null) { + if (!user) { + return ''; + } + + const firstName = String(user.first_name || '').trim(); + const lastName = String(user.last_name || '').trim(); + return `${firstName} ${lastName}`.trim() || firstName || String(user.username || '').trim(); +} + +export function useTelegramMiniApp() { + const webApp = computed(() => { + if (!import.meta.client) { + return null; + } + + return window.Telegram?.WebApp ?? null; + }); + + const initData = computed(() => String(webApp.value?.initData || '').trim()); + const user = computed(() => webApp.value?.initDataUnsafe?.user ?? null); + const startParam = computed(() => String(webApp.value?.initDataUnsafe?.start_param || '').trim()); + const isAvailable = computed(() => Boolean(initData.value)); + const displayName = computed(() => buildDisplayName(user.value)); + + return { + webApp, + initData, + user, + startParam, + isAvailable, + displayName, + }; +} diff --git a/app/middleware/auth.global.ts b/app/middleware/auth.global.ts index 6c18fd2..ec1151b 100644 --- a/app/middleware/auth.global.ts +++ b/app/middleware/auth.global.ts @@ -4,11 +4,22 @@ export default defineNuxtRouteMiddleware((to) => { const authToken = useCookie(authCookieName); const isLoginPage = to.path === '/login'; + const loginToken = typeof to.query.login_token === 'string' ? to.query.login_token.trim() : ''; + const nextPath = to.fullPath.startsWith('/') ? to.fullPath : '/'; + if (!authToken.value && !isLoginPage) { - return navigateTo('/login'); + return navigateTo({ + path: '/login', + query: { + next: nextPath, + }, + }); } - if (authToken.value && isLoginPage) { - return navigateTo('/'); + if (authToken.value && isLoginPage && !loginToken) { + const requestedNextPath = typeof to.query.next === 'string' && to.query.next.startsWith('/') + ? to.query.next + : '/'; + return navigateTo(requestedNextPath); } }); diff --git a/app/pages/login.vue b/app/pages/login.vue index 627608a..7d9c194 100644 --- a/app/pages/login.vue +++ b/app/pages/login.vue @@ -6,6 +6,7 @@ import { VerifyLoginCodeDocument, } from '~/composables/graphql/generated'; import { useMessengerStart } from '~/composables/useMessengerStart'; +import { useTelegramMiniApp } from '~/composables/useTelegramMiniApp'; const config = useRuntimeConfig(); const route = useRoute(); @@ -30,19 +31,31 @@ const requestCodeMutation = useMutation(RequestLoginCodeDocument, { throws: 'nev const verifyCodeMutation = useMutation(VerifyLoginCodeDocument, { throws: 'never' }); const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument, { throws: 'never' }); const { openMessengerBot, pendingChannel } = useMessengerStart(); +const { isAvailable: isTelegramMiniApp, initData: telegramMiniAppInitData, displayName: telegramMiniAppDisplayName } = useTelegramMiniApp(); const telegramBotUrl = computed(() => config.public.telegramBotUrl || ''); const maxBotUrl = computed(() => config.public.maxBotUrl || ''); const normalizedEmail = computed(() => email.value.trim().toLowerCase()); const isEmailReady = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail.value)); +const nextPath = computed(() => + typeof route.query.next === 'string' && route.query.next.startsWith('/') + ? route.query.next + : '', +); +const telegramMiniAppMode = ref<'idle' | 'checking' | 'authenticated' | 'needs_email'>('idle'); async function finalizeSession(accessToken: string) { authCookie.value = accessToken; } -async function navigateAfterLogin(user: { company?: { id: string } | null }) { - if (!user.company?.id) { +async function navigateAfterLogin(user: { company?: { id: string } | null; companyId?: string | null }) { + if (nextPath.value) { + await navigateTo(nextPath.value); + return; + } + + if (!user.company?.id && !user.companyId) { await navigateTo('/profile'); return; } @@ -63,6 +76,9 @@ function normalizeApolloErrorMessage(message: string) { if (message.includes('User for this destination was not found.')) { return 'Пользователь с таким e-mail не найден. Вход доступен только для созданных аккаунтов.'; } + if (message.includes('Telegram initData')) { + return 'Не получилось проверить Telegram Mini App. Откройте кабинет из Telegram заново.'; + } return message; } @@ -143,6 +159,7 @@ async function verifyCode() { } await finalizeSession(payload.accessToken); + await connectTelegramMiniApp(); await navigateAfterLogin(payload.user); } @@ -166,6 +183,67 @@ async function consumeLoginToken(loginToken: string) { await navigateAfterLogin(payload.user); } +async function connectTelegramMiniApp() { + if (!isTelegramMiniApp.value || !telegramMiniAppInitData.value) { + return; + } + if (telegramMiniAppMode.value === 'authenticated') { + return; + } + + try { + await $fetch('/api/auth/telegram-mini-app/connect', { + method: 'POST', + body: { + initData: telegramMiniAppInitData.value, + }, + }); + } catch (error) { + console.error('telegram mini app connect failed', error); + } +} + +async function tryTelegramMiniAppLogin() { + if (!isTelegramMiniApp.value || !telegramMiniAppInitData.value) { + return; + } + + telegramMiniAppMode.value = 'checking'; + + try { + const payload = await $fetch<{ + ok: true; + authenticated: boolean; + accessToken?: string; + user?: { company?: { id: string } | null; companyId?: string | null }; + telegramUser?: { displayName?: string }; + }>('/api/auth/telegram-mini-app/session', { + method: 'POST', + body: { + initData: telegramMiniAppInitData.value, + }, + }); + + if (payload.authenticated && payload.accessToken && payload.user) { + telegramMiniAppMode.value = 'authenticated'; + await finalizeSession(payload.accessToken); + await navigateAfterLogin(payload.user); + return; + } + + telegramMiniAppMode.value = 'needs_email'; + feedback.value = payload.telegramUser?.displayName + ? `${payload.telegramUser.displayName}, введите рабочий e-mail. После входа мы привяжем этот Telegram к вашему кабинету.` + : 'Введите рабочий e-mail. После входа мы привяжем этот Telegram к вашему кабинету.'; + feedbackTone.value = 'success'; + } catch (error) { + telegramMiniAppMode.value = 'idle'; + const message = error instanceof Error ? error.message : 'Не получилось проверить Telegram Mini App.'; + feedback.value = normalizeApolloErrorMessage(message); + feedbackTone.value = 'error'; + } +} + async function startMessengerLogin(channel: 'TELEGRAM' | 'MAX') { if (!isEmailReady.value) { feedback.value = 'Введите корректный email.'; @@ -234,7 +312,10 @@ onMounted(async () => { const loginToken = typeof route.query.login_token === 'string' ? route.query.login_token : ''; if (loginToken) { await consumeLoginToken(loginToken); + return; } + + await tryTelegramMiniAppLogin(); }); onBeforeUnmount(() => { @@ -248,6 +329,18 @@ onBeforeUnmount(() => {

Вход в личный кабинет

+

+ Проверяем аккаунт Telegram… +

+

+ {{ telegramMiniAppDisplayName ? `Вы вошли из Telegram как ${telegramMiniAppDisplayName}.` : 'Вы открыли кабинет внутри Telegram.' }} +

@@ -265,6 +358,7 @@ onBeforeUnmount(() => {