Files
web-frontend/app/pages/login.vue
2026-04-07 09:58:20 +07:00

439 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import {
ConsumeLoginTokenDocument,
RequestLoginCodeDocument,
VerifyLoginCodeDocument,
} from '~/composables/graphql/generated';
import { useMessengerMiniApp } from '~/composables/useMessengerMiniApp';
import { useMessengerStart } from '~/composables/useMessengerStart';
const config = useRuntimeConfig();
const route = useRoute();
const authCookieName = config.public.authCookieName || 'fregat_auth_token';
const authCookie = useCookie<string | null>(authCookieName, {
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
});
const step = ref<'request' | 'verify'>('request');
const email = ref('');
const challengeToken = ref('');
const maskedEmail = ref('');
const expiresAt = ref('');
const code = ref('');
const feedback = ref('');
const feedbackTone = ref<'success' | 'error'>('success');
const requestCodeMutation = useMutation(RequestLoginCodeDocument, { throws: 'never' });
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument, { throws: 'never' });
const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument, { throws: 'never' });
const { openMessengerBot, pendingChannel } = useMessengerStart();
const {
channel: messengerMiniAppChannel,
channelLabel: messengerMiniAppChannelLabel,
displayName: messengerMiniAppDisplayName,
initData: messengerMiniAppInitData,
isAvailable: isMessengerMiniApp,
} = useMessengerMiniApp();
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 canUseTelegramLogin = computed(() => messengerMiniAppChannel.value !== 'TELEGRAM' && Boolean(telegramBotUrl.value));
const canUseMaxLogin = computed(() => messengerMiniAppChannel.value !== 'MAX' && Boolean(maxBotUrl.value));
const hasMessengerButtons = computed(() => canUseTelegramLogin.value || canUseMaxLogin.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; companyId?: string | null }) {
if (nextPath.value) {
await navigateTo(nextPath.value);
return;
}
if (!user.company?.id && !user.companyId) {
await navigateTo('/profile');
return;
}
await navigateTo('/');
}
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 заново.';
}
if (message.includes('MAX initData')) {
return 'Не получилось проверить MAX Mini App. Откройте кабинет из MAX заново.';
}
return message;
}
function requestCodeFailedMessage() {
const mutationError = requestCodeMutation.error.value;
if (!mutationError) {
return 'Не получилось отправить код.';
}
const firstGraphqlError = mutationError.graphQLErrors[0]?.message;
if (firstGraphqlError) {
return normalizeApolloErrorMessage(firstGraphqlError);
}
if (mutationError.message) {
return normalizeApolloErrorMessage(mutationError.message);
}
return 'Не получилось отправить код.';
}
async function requestCode() {
if (!isEmailReady.value) {
feedback.value = 'Введите корректный email.';
feedbackTone.value = 'error';
return;
}
if (requestCodeMutation.loading.value) {
return;
}
feedback.value = '';
const result = await requestCodeMutation.mutate({
input: {
channel: 'EMAIL',
destination: normalizedEmail.value,
},
});
const payload = result?.data?.requestLoginCode;
if (!payload) {
feedback.value = requestCodeFailedMessage();
feedbackTone.value = 'error';
return;
}
challengeToken.value = payload.challengeToken;
maskedEmail.value = payload.destination;
expiresAt.value = new Date(payload.expiresAt).toLocaleString();
code.value = '';
feedback.value = `Код отправлен на ${payload.destination}.`;
feedbackTone.value = 'success';
step.value = 'verify';
}
async function verifyCode() {
if (!challengeToken.value || !code.value.trim()) {
feedback.value = 'Введите код из письма.';
feedbackTone.value = 'error';
return;
}
feedback.value = '';
const result = await verifyCodeMutation.mutate({
input: {
challengeToken: challengeToken.value,
code: code.value.trim(),
},
});
const payload = result?.data?.verifyLoginCode;
if (!payload) {
feedback.value = 'Не получилось выполнить вход.';
feedbackTone.value = 'error';
return;
}
await finalizeSession(payload.accessToken);
await connectMessengerMiniApp();
await navigateAfterLogin(payload.user);
}
function returnToRequestStep() {
step.value = 'request';
code.value = '';
feedback.value = '';
challengeToken.value = '';
maskedEmail.value = '';
expiresAt.value = '';
}
async function consumeLoginToken(loginToken: string) {
feedback.value = '';
const result = await consumeLoginTokenMutation.mutate({
token: loginToken,
});
const payload = result?.data?.consumeLoginToken;
if (!payload) {
feedback.value = 'Временный токен входа недействителен.';
feedbackTone.value = 'error';
return;
}
await finalizeSession(payload.accessToken);
const nextPath = typeof route.query.next === 'string' ? route.query.next : '';
if (nextPath.startsWith('/')) {
await navigateTo(nextPath);
return;
}
await navigateAfterLogin(payload.user);
}
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') {
return;
}
try {
await $fetch(resolveMessengerMiniAppEndpoint('connect'), {
method: 'POST',
body: {
initData: messengerMiniAppInitData.value,
},
});
} catch (error) {
console.error('messenger mini app connect failed', error);
}
}
async function tryMessengerMiniAppLogin() {
if (!isMessengerMiniApp.value || !messengerMiniAppInitData.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 };
maxUser?: { displayName?: string };
}>(resolveMessengerMiniAppEndpoint('session'), {
method: 'POST',
body: {
initData: messengerMiniAppInitData.value,
},
});
if (payload.authenticated && payload.accessToken && payload.user) {
telegramMiniAppMode.value = 'authenticated';
await finalizeSession(payload.accessToken);
await navigateAfterLogin(payload.user);
return;
}
telegramMiniAppMode.value = 'needs_email';
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';
const message = typeof error === 'object' && error && 'data' in error && typeof error.data === 'object' && error.data && 'error' in error.data
? String(error.data.error || '')
: error instanceof Error
? error.message
: `Не получилось проверить ${resolveMessengerMiniAppLabel()}.`;
feedback.value = normalizeApolloErrorMessage(message);
feedbackTone.value = 'error';
}
}
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,
redirectPath: `/profile/notifications/success?connected=${channel.toLowerCase()}`,
});
}
onMounted(async () => {
const loginToken = typeof route.query.login_token === 'string' ? route.query.login_token : '';
if (loginToken) {
await consumeLoginToken(loginToken);
return;
}
await tryMessengerMiniAppLogin();
});
</script>
<template>
<section class="mx-auto flex w-full max-w-[540px] items-center justify-center py-6 md:py-10">
<div class="surface-card w-full rounded-[32px] border border-white/70 px-6 py-6 shadow-[0_26px_70px_rgba(18,56,36,0.12)] md:px-8 md:py-8">
<div class="space-y-6">
<div class="space-y-3 text-center">
<p class="text-[11px] font-bold uppercase tracking-[0.22em] text-[#6a8a76]">Фрегат</p>
<div class="space-y-2">
<h1 class="text-3xl font-black tracking-[-0.04em] text-[#123824] md:text-4xl">Вход</h1>
<p class="text-sm leading-6 text-[#5c7b69]">
Войдите по рабочему e-mail и коду из письма.
</p>
</div>
</div>
<div
v-if="telegramMiniAppMode === 'checking' || isMessengerMiniApp"
class="rounded-[24px] border px-4 py-3 text-sm leading-6"
:class="telegramMiniAppMode === 'checking'
? 'border-[#dce9e1] bg-[#f7fbf9] text-[#355947]'
: 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'"
>
<template v-if="telegramMiniAppMode === 'checking'">
{{ `Проверяем аккаунт ${resolveMessengerMiniAppLabel()}` }}
</template>
<template v-else>
{{
messengerMiniAppDisplayName
? `Вы вошли из ${resolveMessengerMiniAppLabel()} как ${messengerMiniAppDisplayName}.`
: `Вы открыли кабинет внутри ${resolveMessengerMiniAppLabel()}.`
}}
</template>
</div>
<div v-if="step === 'request'" class="space-y-5">
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">E-mail</span>
<input
v-model="email"
type="email"
class="input manager-field h-14 w-full px-4 text-base text-[#123824]"
placeholder="name@company.com"
@keydown.enter.prevent="requestCode"
>
</label>
<button
class="btn h-12 w-full rounded-full border-0 bg-[#123824] text-white shadow-[0_16px_32px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579]"
:disabled="requestCodeMutation.loading.value || !isEmailReady"
@click="requestCode"
>
{{ requestCodeMutation.loading.value ? 'Отправляем код…' : 'Получить код' }}
</button>
<div v-if="hasMessengerButtons" class="space-y-3">
<div class="flex items-center gap-3">
<span class="h-px flex-1 bg-[#e2ece6]" />
<span class="text-[11px] font-bold uppercase tracking-[0.18em] text-[#7a9386]">или войти через</span>
<span class="h-px flex-1 bg-[#e2ece6]" />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<button
v-if="canUseTelegramLogin"
class="btn h-12 rounded-full border border-[#d7e6dc] bg-white text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8] disabled:border-[#e6ede9] disabled:bg-[#f8fbf9] disabled:text-[#91a79b]"
:disabled="pendingChannel === 'TELEGRAM' || !isEmailReady"
@click="startMessengerLogin('TELEGRAM')"
>
{{ pendingChannel === 'TELEGRAM' ? 'Открываем Telegram…' : 'Telegram' }}
</button>
<button
v-if="canUseMaxLogin"
class="btn h-12 rounded-full border border-[#d7e6dc] bg-white text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8] disabled:border-[#e6ede9] disabled:bg-[#f8fbf9] disabled:text-[#91a79b]"
:disabled="pendingChannel === 'MAX' || !isEmailReady"
@click="startMessengerLogin('MAX')"
>
{{ pendingChannel === 'MAX' ? 'Открываем Max…' : 'Max' }}
</button>
</div>
</div>
</div>
<div v-else class="space-y-5">
<div class="rounded-[24px] bg-[#f5faf7] px-4 py-3 text-sm text-[#355947]">
Код отправлен на <span class="font-semibold text-[#123824]">{{ maskedEmail }}</span>
</div>
<label class="block space-y-2">
<span class="text-sm font-semibold text-[#355947]">Код из письма</span>
<input
v-model="code"
type="text"
maxlength="6"
class="input manager-field h-14 w-full px-4 text-base tracking-[0.22em] text-[#123824]"
placeholder="123456"
@keydown.enter.prevent="verifyCode"
>
</label>
<div class="space-y-3">
<button
class="btn h-12 w-full rounded-full border-0 bg-[#123824] text-white shadow-[0_16px_32px_rgba(18,56,36,0.18)] hover:bg-[#0f2f20] disabled:border-0 disabled:bg-[#cfd8d2] disabled:text-[#6f8579]"
:disabled="verifyCodeMutation.loading.value || !code.trim()"
@click="verifyCode"
>
{{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }}
</button>
<button
class="btn h-12 w-full rounded-full border border-[#d7e6dc] bg-white text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8]"
@click="returnToRequestStep"
>
Изменить e-mail
</button>
</div>
<p class="text-xs text-[#7a9386]">Код действует до {{ expiresAt }}</p>
</div>
<div
v-if="feedback"
class="rounded-[24px] border px-4 py-3 text-sm leading-6"
:class="feedbackTone === 'success'
? 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'
: 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'"
>
{{ feedback }}
</div>
</div>
</div>
</section>
</template>