From 6cadd5160c625ae07c474b9c8c7abeaca9446560 Mon Sep 17 00:00:00 2001
From: Ruslan Bakiev <572431+veikab@users.noreply.github.com>
Date: Fri, 3 Apr 2026 18:12:17 +0700
Subject: [PATCH] feat(auth): request secure messenger start sessions
---
app/composables/useMessengerBotLink.ts | 19 ++------
app/composables/useMessengerStart.ts | 49 +++++++++++++++++++++
app/pages/login.vue | 58 +++++++++++++++----------
app/pages/notifications.vue | 51 ++++++++++++++++------
app/pages/profile/notifications.vue | 51 ++++++++++++++++------
server/api/auth/messenger-start.post.ts | 27 ++++++++++++
6 files changed, 190 insertions(+), 65 deletions(-)
create mode 100644 app/composables/useMessengerStart.ts
create mode 100644 server/api/auth/messenger-start.post.ts
diff --git a/app/composables/useMessengerBotLink.ts b/app/composables/useMessengerBotLink.ts
index e3f1e38..d788b9d 100644
--- a/app/composables/useMessengerBotLink.ts
+++ b/app/composables/useMessengerBotLink.ts
@@ -1,21 +1,10 @@
-function toBase64Url(value: string) {
- if (typeof Buffer !== 'undefined') {
- return Buffer.from(value, 'utf8').toString('base64url');
- }
-
- return btoa(value)
- .replace(/\+/g, '-')
- .replace(/\//g, '_')
- .replace(/=+$/g, '');
-}
-
-export function buildMessengerBotStartUrl(baseUrl: string, email: string) {
- const normalizedEmail = email.trim().toLowerCase();
- if (!baseUrl || !normalizedEmail) {
+export function buildMessengerBotStartUrl(baseUrl: string, startToken: string) {
+ const normalizedToken = startToken.trim();
+ if (!baseUrl || !normalizedToken) {
return '';
}
- const payload = encodeURIComponent(toBase64Url(`login:${normalizedEmail}`));
+ const payload = encodeURIComponent(normalizedToken);
const separator = baseUrl.includes('?') ? '&' : '?';
return `${baseUrl}${separator}start=${payload}`;
}
diff --git a/app/composables/useMessengerStart.ts b/app/composables/useMessengerStart.ts
new file mode 100644
index 0000000..a88d68f
--- /dev/null
+++ b/app/composables/useMessengerStart.ts
@@ -0,0 +1,49 @@
+import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink';
+
+type MessengerChannel = 'TELEGRAM' | 'MAX';
+
+type MessengerStartResponse = {
+ ok: true;
+ startToken: string;
+ expiresAt: string;
+ mode: 'login' | 'connect';
+};
+
+type MessengerStartInput = {
+ channel: MessengerChannel;
+ baseUrl: string;
+ email?: string;
+};
+
+export function useMessengerStart() {
+ const pendingChannel = ref(null);
+
+ async function openMessengerBot({ channel, baseUrl, email }: MessengerStartInput) {
+ pendingChannel.value = channel;
+
+ const payloadPromise = $fetch('/api/auth/messenger-start', {
+ method: 'POST',
+ body: {
+ channel,
+ email,
+ },
+ });
+ payloadPromise.finally(() => {
+ pendingChannel.value = null;
+ });
+
+ const payload = await payloadPromise;
+
+ const startUrl = buildMessengerBotStartUrl(baseUrl, payload.startToken);
+ if (import.meta.client) {
+ window.open(startUrl, '_blank', 'noopener,noreferrer');
+ }
+
+ return payload;
+ }
+
+ return {
+ pendingChannel,
+ openMessengerBot,
+ };
+}
diff --git a/app/pages/login.vue b/app/pages/login.vue
index 2f4d8b2..12e6035 100644
--- a/app/pages/login.vue
+++ b/app/pages/login.vue
@@ -5,7 +5,7 @@ import {
RequestLoginCodeDocument,
VerifyLoginCodeDocument,
} from '~/composables/graphql/generated';
-import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink';
+import { useMessengerStart } from '~/composables/useMessengerStart';
const config = useRuntimeConfig();
const route = useRoute();
@@ -29,6 +29,7 @@ const lastRequestedEmail = ref('');
const requestCodeMutation = useMutation(RequestLoginCodeDocument, { throws: 'never' });
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument, { throws: 'never' });
const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument, { throws: 'never' });
+const { openMessengerBot, pendingChannel } = useMessengerStart();
const telegramBotUrl = computed(() => config.public.telegramBotUrl || '');
const maxBotUrl = computed(() => config.public.maxBotUrl || '');
@@ -36,13 +37,6 @@ const maxBotUrl = computed(() => config.public.maxBotUrl || '');
const normalizedEmail = computed(() => email.value.trim().toLowerCase());
const isEmailReady = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail.value));
-const telegramLoginUrl = computed(() =>
- isEmailReady.value ? buildMessengerBotStartUrl(telegramBotUrl.value, normalizedEmail.value) : '',
-);
-const maxLoginUrl = computed(() =>
- isEmailReady.value ? buildMessengerBotStartUrl(maxBotUrl.value, normalizedEmail.value) : '',
-);
-
async function finalizeSession(accessToken: string) {
authCookie.value = accessToken;
}
@@ -167,6 +161,28 @@ async function consumeLoginToken(loginToken: string) {
await navigateAfterLogin(payload.user);
}
+async function startMessengerLogin(channel: 'TELEGRAM' | 'MAX') {
+ if (!isEmailReady.value) {
+ feedback.value = 'Введите корректный email.';
+ feedbackTone.value = 'error';
+ return;
+ }
+
+ const baseUrl = channel === 'TELEGRAM' ? telegramBotUrl.value : maxBotUrl.value;
+ if (!baseUrl) {
+ feedback.value = 'Ссылка на бота пока не настроена.';
+ feedbackTone.value = 'error';
+ return;
+ }
+
+ feedback.value = '';
+ await openMessengerBot({
+ channel,
+ baseUrl,
+ email: normalizedEmail.value,
+ });
+}
+
function scheduleAutoRequest() {
clearAutoRequestTimer();
@@ -242,24 +258,22 @@ onBeforeUnmount(() => {
diff --git a/app/pages/notifications.vue b/app/pages/notifications.vue
index 04c6e3d..613c655 100644
--- a/app/pages/notifications.vue
+++ b/app/pages/notifications.vue
@@ -6,12 +6,13 @@ import {
MyNotificationHistoryDocument,
SendTestMessengerMessageDocument,
} from '~/composables/graphql/generated';
-import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink';
+import { useMessengerStart } from '~/composables/useMessengerStart';
const selectedChannel = ref<'TELEGRAM' | 'MAX'>('TELEGRAM');
const customMessage = ref('Тест канала уведомлений Fregat');
const feedback = ref('');
const config = useRuntimeConfig();
+const { openMessengerBot, pendingChannel } = useMessengerStart();
const meQuery = useQuery(MeDocument);
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
@@ -56,12 +57,24 @@ function buildBotConnectUrl(baseUrl: string) {
return '';
}
- return buildMessengerBotStartUrl(baseUrl, email);
+ return baseUrl;
}
const telegramConnectUrl = computed(() => buildBotConnectUrl(config.public.telegramBotUrl || ''));
const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl || ''));
+async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
+ const baseUrl = channel === 'TELEGRAM' ? telegramConnectUrl.value : maxConnectUrl.value;
+ if (!baseUrl) {
+ return;
+ }
+
+ await openMessengerBot({
+ channel,
+ baseUrl,
+ });
+}
+
async function sendTest() {
feedback.value = '';
if (!activeConnection.value) {
@@ -114,15 +127,20 @@ async function sendTest() {
-
- {{ telegramConnection ? 'Переподключить Telegram' : 'Подключить Telegram' }}
-
+ {{
+ pendingChannel === 'TELEGRAM'
+ ? 'Открываем Telegram…'
+ : telegramConnection
+ ? 'Переподключить Telegram'
+ : 'Подключить Telegram'
+ }}
+
@@ -139,15 +157,20 @@ async function sendTest() {
-
- {{ maxConnection ? 'Переподключить Max' : 'Подключить Max' }}
-
+ {{
+ pendingChannel === 'MAX'
+ ? 'Открываем Max…'
+ : maxConnection
+ ? 'Переподключить Max'
+ : 'Подключить Max'
+ }}
+
diff --git a/app/pages/profile/notifications.vue b/app/pages/profile/notifications.vue
index 09152fb..9782687 100644
--- a/app/pages/profile/notifications.vue
+++ b/app/pages/profile/notifications.vue
@@ -4,7 +4,7 @@ import {
MeDocument,
MyMessengerConnectionsDocument,
} from '~/composables/graphql/generated';
-import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink';
+import { useMessengerStart } from '~/composables/useMessengerStart';
type MessengerItem = {
type: 'TELEGRAM' | 'MAX';
@@ -15,6 +15,7 @@ type MessengerItem = {
const config = useRuntimeConfig();
const meQuery = useQuery(MeDocument);
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
+const { openMessengerBot, pendingChannel } = useMessengerStart();
const telegramConnection = computed(() =>
connectionsQuery.result.value?.myMessengerConnections?.find(
@@ -34,11 +35,23 @@ function buildBotConnectUrl(baseUrl: string) {
return '';
}
- return buildMessengerBotStartUrl(baseUrl, accountEmail);
+ return baseUrl;
}
const telegramConnectUrl = computed(() => buildBotConnectUrl(config.public.telegramBotUrl || ''));
const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl || ''));
+
+async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
+ const baseUrl = channel === 'TELEGRAM' ? telegramConnectUrl.value : maxConnectUrl.value;
+ if (!baseUrl) {
+ return;
+ }
+
+ await openMessengerBot({
+ channel,
+ baseUrl,
+ });
+}
@@ -57,15 +70,20 @@ const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl
{{ telegramConnection ? `Подключен: ${telegramConnection.channelId}` : 'Не подключен' }}
-
- {{ telegramConnection ? 'Переподключить Telegram' : 'Подключить Telegram' }}
-
+ {{
+ pendingChannel === 'TELEGRAM'
+ ? 'Открываем Telegram…'
+ : telegramConnection
+ ? 'Переподключить Telegram'
+ : 'Подключить Telegram'
+ }}
+
@@ -73,15 +91,20 @@ const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl
{{ maxConnection ? `Подключен: ${maxConnection.channelId}` : 'Не подключен' }}
-
- {{ maxConnection ? 'Переподключить Max' : 'Подключить Max' }}
-
+ {{
+ pendingChannel === 'MAX'
+ ? 'Открываем Max…'
+ : maxConnection
+ ? 'Переподключить Max'
+ : 'Подключить Max'
+ }}
+
diff --git a/server/api/auth/messenger-start.post.ts b/server/api/auth/messenger-start.post.ts
new file mode 100644
index 0000000..6c7f083
--- /dev/null
+++ b/server/api/auth/messenger-start.post.ts
@@ -0,0 +1,27 @@
+export default defineEventHandler(async (event) => {
+ const config = useRuntimeConfig(event);
+ const backendUrl = new URL(config.backendGraphqlUrl);
+ const endpoint = `${backendUrl.origin}/auth/messenger-start`;
+ const body = await readBody(event);
+
+ const cookie = getHeader(event, 'cookie');
+ const authorization = getHeader(event, 'authorization');
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ ...(cookie ? { cookie } : {}),
+ ...(authorization ? { authorization } : {}),
+ },
+ body: JSON.stringify(body),
+ });
+
+ setResponseStatus(event, response.status);
+ const contentType = response.headers.get('content-type');
+ if (contentType) {
+ setHeader(event, 'content-type', contentType);
+ }
+
+ return await response.json();
+});