diff --git a/app/composables/useMaxMiniApp.ts b/app/composables/useMaxMiniApp.ts new file mode 100644 index 0000000..8b98a71 --- /dev/null +++ b/app/composables/useMaxMiniApp.ts @@ -0,0 +1,63 @@ +type MaxMiniAppUser = { + id: number | string; + first_name?: string; + last_name?: string; + username?: string; + language_code?: string; + photo_url?: string; +}; + +type MaxWebApp = { + initData?: string; + initDataUnsafe?: { + user?: MaxMiniAppUser; + start_param?: string; + }; + ready?: () => void; + close?: () => void; + openLink?: (url: string) => void; + openMaxLink?: (url: string) => void; + platform?: string; + version?: string; +}; + +declare global { + interface Window { + WebApp?: MaxWebApp; + } +} + +function buildDisplayName(user: MaxMiniAppUser | 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 useMaxMiniApp() { + const webApp = computed(() => { + if (!import.meta.client) { + return null; + } + + return window.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/composables/useMessengerMiniApp.ts b/app/composables/useMessengerMiniApp.ts new file mode 100644 index 0000000..cc09fff --- /dev/null +++ b/app/composables/useMessengerMiniApp.ts @@ -0,0 +1,45 @@ +import { decodeMessengerMiniAppStartParam } from '~/composables/useMessengerMiniAppStart'; + +export function useMessengerMiniApp() { + const telegramMiniApp = useTelegramMiniApp(); + const maxMiniApp = useMaxMiniApp(); + + const channel = computed<'TELEGRAM' | 'MAX' | null>(() => { + if (maxMiniApp.isAvailable.value) { + return 'MAX'; + } + + if (telegramMiniApp.isAvailable.value) { + return 'TELEGRAM'; + } + + return null; + }); + + const isAvailable = computed(() => channel.value !== null); + const initData = computed(() => + channel.value === 'MAX' ? maxMiniApp.initData.value : telegramMiniApp.initData.value, + ); + const startParam = computed(() => + channel.value === 'MAX' ? maxMiniApp.startParam.value : telegramMiniApp.startParam.value, + ); + const startPath = computed(() => decodeMessengerMiniAppStartParam(startParam.value)); + const displayName = computed(() => + channel.value === 'MAX' ? maxMiniApp.displayName.value : telegramMiniApp.displayName.value, + ); + const channelLabel = computed(() => + channel.value === 'MAX' ? 'MAX' : channel.value === 'TELEGRAM' ? 'Telegram' : '', + ); + + return { + channel, + channelLabel, + isAvailable, + initData, + startParam, + startPath, + displayName, + telegramMiniApp, + maxMiniApp, + }; +} diff --git a/app/composables/useMessengerMiniAppStart.ts b/app/composables/useMessengerMiniAppStart.ts new file mode 100644 index 0000000..16dc42d --- /dev/null +++ b/app/composables/useMessengerMiniAppStart.ts @@ -0,0 +1,125 @@ +const MESSENGER_MINI_APP_PATH_PREFIX = 'path_'; + +function getBuffer() { + return (globalThis as typeof globalThis & { + Buffer?: { + from(input: string | Uint8Array, encoding?: string): { + toString(encoding?: string): string; + }; + }; + }).Buffer; +} + +function encodeUtf8(value: string) { + if (typeof TextEncoder !== 'undefined') { + return new TextEncoder().encode(value); + } + + const buffer = getBuffer(); + if (buffer) { + return Uint8Array.from(buffer.from(value, 'utf8')); + } + + return Uint8Array.from(value.split('').map((char) => char.charCodeAt(0))); +} + +function decodeUtf8(bytes: Uint8Array) { + if (typeof TextDecoder !== 'undefined') { + return new TextDecoder().decode(bytes); + } + + const buffer = getBuffer(); + if (buffer) { + return buffer.from(bytes).toString('utf8'); + } + + return String.fromCharCode(...bytes); +} + +function toBase64(bytes: Uint8Array) { + const buffer = getBuffer(); + if (buffer) { + return buffer.from(bytes).toString('base64'); + } + + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function fromBase64(value: string) { + const buffer = getBuffer(); + if (buffer) { + return Uint8Array.from(buffer.from(value, 'base64')); + } + + const binary = atob(value); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +function normalizePath(value: string) { + const normalizedPath = String(value || '').trim(); + return normalizedPath.startsWith('/') ? normalizedPath : ''; +} + +function toBase64Url(value: string) { + return toBase64(encodeUtf8(value)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function fromBase64Url(value: string) { + const normalizedValue = value.replace(/-/g, '+').replace(/_/g, '/'); + const padding = normalizedValue.length % 4 === 0 ? '' : '='.repeat(4 - (normalizedValue.length % 4)); + return decodeUtf8(fromBase64(`${normalizedValue}${padding}`)); +} + +function readWindowStartParam() { + if (!import.meta.client) { + return ''; + } + + const telegramStartParam = String(window.Telegram?.WebApp?.initDataUnsafe?.start_param || '').trim(); + if (telegramStartParam) { + return telegramStartParam; + } + + return String(window.WebApp?.initDataUnsafe?.start_param || '').trim(); +} + +export function encodeMessengerMiniAppPath(path: string) { + const normalizedPath = normalizePath(path); + if (!normalizedPath) { + return ''; + } + + return `${MESSENGER_MINI_APP_PATH_PREFIX}${toBase64Url(normalizedPath)}`; +} + +export function decodeMessengerMiniAppStartParam(startParam: string) { + const normalizedStartParam = String(startParam || '').trim(); + if (!normalizedStartParam) { + return ''; + } + + if (normalizedStartParam.startsWith('/')) { + return normalizePath(normalizedStartParam); + } + + if (!normalizedStartParam.startsWith(MESSENGER_MINI_APP_PATH_PREFIX)) { + return ''; + } + + try { + return normalizePath(fromBase64Url(normalizedStartParam.slice(MESSENGER_MINI_APP_PATH_PREFIX.length))); + } catch { + return ''; + } +} + +export function readMessengerMiniAppStartPath() { + return decodeMessengerMiniAppStartParam(readWindowStartParam()); +} diff --git a/app/middleware/auth.global.ts b/app/middleware/auth.global.ts index ec1151b..7e6750c 100644 --- a/app/middleware/auth.global.ts +++ b/app/middleware/auth.global.ts @@ -1,3 +1,5 @@ +import { readMessengerMiniAppStartPath } from '~/composables/useMessengerMiniAppStart'; + export default defineNuxtRouteMiddleware((to) => { const config = useRuntimeConfig(); const authCookieName = config.public.authCookieName || 'fregat_auth_token'; @@ -5,7 +7,8 @@ export default defineNuxtRouteMiddleware((to) => { 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 : '/'; + const miniAppStartPath = readMessengerMiniAppStartPath(); + const nextPath = miniAppStartPath || (to.fullPath.startsWith('/') ? to.fullPath : '/'); if (!authToken.value && !isLoginPage) { return navigateTo({ @@ -19,7 +22,11 @@ export default defineNuxtRouteMiddleware((to) => { if (authToken.value && isLoginPage && !loginToken) { const requestedNextPath = typeof to.query.next === 'string' && to.query.next.startsWith('/') ? to.query.next - : '/'; + : miniAppStartPath || '/'; return navigateTo(requestedNextPath); } + + if (authToken.value && to.path === '/' && miniAppStartPath && miniAppStartPath !== '/') { + return navigateTo(miniAppStartPath); + } }); diff --git a/app/pages/login.vue b/app/pages/login.vue index c8c9559..07d27ee 100644 --- a/app/pages/login.vue +++ b/app/pages/login.vue @@ -5,8 +5,8 @@ import { RequestLoginCodeDocument, VerifyLoginCodeDocument, } from '~/composables/graphql/generated'; +import { useMessengerMiniApp } from '~/composables/useMessengerMiniApp'; import { useMessengerStart } from '~/composables/useMessengerStart'; -import { useTelegramMiniApp } from '~/composables/useTelegramMiniApp'; const config = useRuntimeConfig(); const route = useRoute(); @@ -31,7 +31,13 @@ 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 { + channel: messengerMiniAppChannel, + channelLabel: messengerMiniAppChannelLabel, + displayName: messengerMiniAppDisplayName, + initData: messengerMiniAppInitData, + isAvailable: isMessengerMiniApp, +} = useMessengerMiniApp(); const telegramBotUrl = computed(() => config.public.telegramBotUrl || ''); const maxBotUrl = computed(() => config.public.maxBotUrl || ''); @@ -79,6 +85,9 @@ function normalizeApolloErrorMessage(message: string) { if (message.includes('Telegram initData')) { return 'Не получилось проверить Telegram Mini App. Откройте кабинет из Telegram заново.'; } + if (message.includes('MAX initData')) { + return 'Не получилось проверить MAX Mini App. Откройте кабинет из MAX заново.'; + } return message; } @@ -159,7 +168,7 @@ async function verifyCode() { } await finalizeSession(payload.accessToken); - await connectTelegramMiniApp(); + await connectMessengerMiniApp(); await navigateAfterLogin(payload.user); } @@ -183,8 +192,20 @@ async function consumeLoginToken(loginToken: string) { await navigateAfterLogin(payload.user); } -async function connectTelegramMiniApp() { - if (!isTelegramMiniApp.value || !telegramMiniAppInitData.value) { +function resolveMessengerMiniAppEndpoint(mode: 'session' | 'connect') { + if (messengerMiniAppChannel.value === 'MAX') { + return `/api/auth/max-mini-app/${mode}`; + } + + return `/api/auth/telegram-mini-app/${mode}`; +} + +function resolveMessengerMiniAppLabel() { + return messengerMiniAppChannelLabel.value || 'Mini App'; +} + +async function connectMessengerMiniApp() { + if (!isMessengerMiniApp.value || !messengerMiniAppInitData.value) { return; } if (telegramMiniAppMode.value === 'authenticated') { @@ -192,19 +213,19 @@ async function connectTelegramMiniApp() { } try { - await $fetch('/api/auth/telegram-mini-app/connect', { + await $fetch(resolveMessengerMiniAppEndpoint('connect'), { method: 'POST', body: { - initData: telegramMiniAppInitData.value, + initData: messengerMiniAppInitData.value, }, }); } catch (error) { - console.error('telegram mini app connect failed', error); + console.error('messenger mini app connect failed', error); } } -async function tryTelegramMiniAppLogin() { - if (!isTelegramMiniApp.value || !telegramMiniAppInitData.value) { +async function tryMessengerMiniAppLogin() { + if (!isMessengerMiniApp.value || !messengerMiniAppInitData.value) { return; } @@ -217,10 +238,11 @@ async function tryTelegramMiniAppLogin() { accessToken?: string; user?: { company?: { id: string } | null; companyId?: string | null }; telegramUser?: { displayName?: string }; - }>('/api/auth/telegram-mini-app/session', { + maxUser?: { displayName?: string }; + }>(resolveMessengerMiniAppEndpoint('session'), { method: 'POST', body: { - initData: telegramMiniAppInitData.value, + initData: messengerMiniAppInitData.value, }, }); @@ -232,9 +254,10 @@ async function tryTelegramMiniAppLogin() { } telegramMiniAppMode.value = 'needs_email'; - feedback.value = payload.telegramUser?.displayName - ? `${payload.telegramUser.displayName}, введите рабочий e-mail. После входа мы привяжем этот Telegram к вашему кабинету.` - : 'Введите рабочий e-mail. После входа мы привяжем этот Telegram к вашему кабинету.'; + const messengerUser = payload.maxUser ?? payload.telegramUser; + feedback.value = messengerUser?.displayName + ? `${messengerUser.displayName}, введите рабочий e-mail. После входа мы привяжем этот ${resolveMessengerMiniAppLabel()} к вашему кабинету.` + : `Введите рабочий e-mail. После входа мы привяжем этот ${resolveMessengerMiniAppLabel()} к вашему кабинету.`; feedbackTone.value = 'success'; } catch (error) { telegramMiniAppMode.value = 'idle'; @@ -242,7 +265,7 @@ async function tryTelegramMiniAppLogin() { ? String(error.data.error || '') : error instanceof Error ? error.message - : 'Не получилось проверить Telegram Mini App.'; + : `Не получилось проверить ${resolveMessengerMiniAppLabel()}.`; feedback.value = normalizeApolloErrorMessage(message); feedbackTone.value = 'error'; } @@ -319,7 +342,7 @@ onMounted(async () => { return; } - await tryTelegramMiniAppLogin(); + await tryMessengerMiniAppLogin(); }); onBeforeUnmount(() => { @@ -337,13 +360,17 @@ onBeforeUnmount(() => { v-if="telegramMiniAppMode === 'checking'" class="mt-2 text-sm text-base-content/70" > - Проверяем аккаунт Telegram… + {{ `Проверяем аккаунт ${resolveMessengerMiniAppLabel()}…` }}

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

@@ -362,7 +389,7 @@ onBeforeUnmount(() => {