feat(auth): request secure messenger start sessions
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
|
||||
49
app/composables/useMessengerStart.ts
Normal file
49
app/composables/useMessengerStart.ts
Normal file
@@ -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<MessengerChannel | null>(null);
|
||||
|
||||
async function openMessengerBot({ channel, baseUrl, email }: MessengerStartInput) {
|
||||
pendingChannel.value = channel;
|
||||
|
||||
const payloadPromise = $fetch<MessengerStartResponse>('/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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {
|
||||
</fieldset>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<a
|
||||
:href="telegramLoginUrl || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:class="{ 'btn-disabled pointer-events-none': !telegramLoginUrl }"
|
||||
:class="{ 'btn-disabled pointer-events-none': !telegramBotUrl || !isEmailReady }"
|
||||
:disabled="pendingChannel === 'TELEGRAM' || !telegramBotUrl || !isEmailReady"
|
||||
@click="startMessengerLogin('TELEGRAM')"
|
||||
>
|
||||
Войти через Telegram
|
||||
</a>
|
||||
<a
|
||||
:href="maxLoginUrl || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{{ pendingChannel === 'TELEGRAM' ? 'Открываем Telegram…' : 'Войти через Telegram' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-accent"
|
||||
:class="{ 'btn-disabled pointer-events-none': !maxLoginUrl }"
|
||||
:class="{ 'btn-disabled pointer-events-none': !maxBotUrl || !isEmailReady }"
|
||||
:disabled="pendingChannel === 'MAX' || !maxBotUrl || !isEmailReady"
|
||||
@click="startMessengerLogin('MAX')"
|
||||
>
|
||||
Войти через Max
|
||||
</a>
|
||||
{{ pendingChannel === 'MAX' ? 'Открываем Max…' : 'Войти через Max' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="requestCodeMutation.loading.value" class="text-sm text-base-content/70">
|
||||
|
||||
@@ -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() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="telegramConnectUrl || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }"
|
||||
:disabled="pendingChannel === 'TELEGRAM' || !telegramConnectUrl"
|
||||
@click="connectMessenger('TELEGRAM')"
|
||||
>
|
||||
{{ telegramConnection ? 'Переподключить Telegram' : 'Подключить Telegram' }}
|
||||
</a>
|
||||
{{
|
||||
pendingChannel === 'TELEGRAM'
|
||||
? 'Открываем Telegram…'
|
||||
: telegramConnection
|
||||
? 'Переподключить Telegram'
|
||||
: 'Подключить Telegram'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,15 +157,20 @@ async function sendTest() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="maxConnectUrl || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
class="btn btn-accent"
|
||||
:class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }"
|
||||
:disabled="pendingChannel === 'MAX' || !maxConnectUrl"
|
||||
@click="connectMessenger('MAX')"
|
||||
>
|
||||
{{ maxConnection ? 'Переподключить Max' : 'Подключить Max' }}
|
||||
</a>
|
||||
{{
|
||||
pendingChannel === 'MAX'
|
||||
? 'Открываем Max…'
|
||||
: maxConnection
|
||||
? 'Переподключить Max'
|
||||
: 'Подключить Max'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -57,15 +70,20 @@ const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl
|
||||
<p class="text-sm opacity-80">
|
||||
{{ telegramConnection ? `Подключен: ${telegramConnection.channelId}` : 'Не подключен' }}
|
||||
</p>
|
||||
<a
|
||||
:href="telegramConnectUrl || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
class="btn btn-secondary mt-3 w-full"
|
||||
:class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }"
|
||||
:disabled="pendingChannel === 'TELEGRAM' || !telegramConnectUrl"
|
||||
@click="connectMessenger('TELEGRAM')"
|
||||
>
|
||||
{{ telegramConnection ? 'Переподключить Telegram' : 'Подключить Telegram' }}
|
||||
</a>
|
||||
{{
|
||||
pendingChannel === 'TELEGRAM'
|
||||
? 'Открываем Telegram…'
|
||||
: telegramConnection
|
||||
? 'Переподключить Telegram'
|
||||
: 'Подключить Telegram'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
|
||||
@@ -73,15 +91,20 @@ const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl
|
||||
<p class="text-sm opacity-80">
|
||||
{{ maxConnection ? `Подключен: ${maxConnection.channelId}` : 'Не подключен' }}
|
||||
</p>
|
||||
<a
|
||||
:href="maxConnectUrl || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
class="btn btn-accent mt-3 w-full"
|
||||
:class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }"
|
||||
:disabled="pendingChannel === 'MAX' || !maxConnectUrl"
|
||||
@click="connectMessenger('MAX')"
|
||||
>
|
||||
{{ maxConnection ? 'Переподключить Max' : 'Подключить Max' }}
|
||||
</a>
|
||||
{{
|
||||
pendingChannel === 'MAX'
|
||||
? 'Открываем Max…'
|
||||
: maxConnection
|
||||
? 'Переподключить Max'
|
||||
: 'Подключить Max'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user