Files
web-frontend/app/pages/login.vue
2026-04-04 14:37:45 +07:00

432 lines
13 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 { useMessengerStart } from '~/composables/useMessengerStart';
import { useTelegramMiniApp } from '~/composables/useTelegramMiniApp';
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 autoRequestTimer = ref<ReturnType<typeof setTimeout> | null>(null);
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 { 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; companyId?: string | null }) {
if (nextPath.value) {
await navigateTo(nextPath.value);
return;
}
if (!user.company?.id && !user.companyId) {
await navigateTo('/profile');
return;
}
await navigateTo('/');
}
function clearAutoRequestTimer() {
if (!autoRequestTimer.value) {
return;
}
clearTimeout(autoRequestTimer.value);
autoRequestTimer.value = null;
}
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;
}
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;
}
clearAutoRequestTimer();
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;
}
lastRequestedEmail.value = normalizedEmail.value;
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 connectTelegramMiniApp();
await navigateAfterLogin(payload.user);
}
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);
}
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 = 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
: 'Не получилось проверить Telegram Mini App.';
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()}`,
});
}
function scheduleAutoRequest() {
clearAutoRequestTimer();
if (step.value !== 'request') {
return;
}
if (!isEmailReady.value) {
return;
}
if (normalizedEmail.value === lastRequestedEmail.value) {
return;
}
autoRequestTimer.value = setTimeout(() => {
void requestCode();
}, 450);
}
function onEmailBlur() {
if (step.value !== 'request') {
return;
}
if (!isEmailReady.value) {
return;
}
if (normalizedEmail.value === lastRequestedEmail.value) {
return;
}
void requestCode();
}
watch([normalizedEmail, step], () => {
if (step.value !== 'request') {
clearAutoRequestTimer();
return;
}
scheduleAutoRequest();
});
onMounted(async () => {
const loginToken = typeof route.query.login_token === 'string' ? route.query.login_token : '';
if (loginToken) {
await consumeLoginToken(loginToken);
return;
}
await tryTelegramMiniAppLogin();
});
onBeforeUnmount(() => {
clearAutoRequestTimer();
});
</script>
<template>
<section class="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-3xl items-center py-8">
<div class="card w-full border border-base-300/60 bg-base-100 shadow-xl">
<div class="card-body p-5 md:p-8">
<div class="mb-4 text-center">
<h1 class="text-3xl font-extrabold">Вход в личный кабинет</h1>
<p
v-if="telegramMiniAppMode === 'checking'"
class="mt-2 text-sm text-base-content/70"
>
Проверяем аккаунт Telegram
</p>
<p
v-else-if="isTelegramMiniApp"
class="mt-2 text-sm text-base-content/70"
>
{{ telegramMiniAppDisplayName ? `Вы вошли из Telegram как ${telegramMiniAppDisplayName}.` : 'Вы открыли кабинет внутри Telegram.' }}
</p>
</div>
<div v-if="step === 'request'" class="space-y-4">
<fieldset class="fieldset">
<legend class="fieldset-legend text-base font-semibold">E-mail</legend>
<input
v-model="email"
type="email"
class="input input-bordered w-full"
placeholder="name@company.com"
@keydown.enter.prevent="requestCode"
@blur="onEmailBlur"
>
</fieldset>
<div class="grid gap-2 sm:grid-cols-2">
<button
v-if="!isTelegramMiniApp"
class="btn btn-secondary"
:class="{ 'btn-disabled pointer-events-none': !telegramBotUrl || !isEmailReady }"
:disabled="pendingChannel === 'TELEGRAM' || !telegramBotUrl || !isEmailReady"
@click="startMessengerLogin('TELEGRAM')"
>
{{ pendingChannel === 'TELEGRAM' ? 'Открываем Telegram…' : 'Войти через Telegram' }}
</button>
<button
class="btn btn-accent"
:class="{ 'btn-disabled pointer-events-none': !maxBotUrl || !isEmailReady }"
:disabled="pendingChannel === 'MAX' || !maxBotUrl || !isEmailReady"
@click="startMessengerLogin('MAX')"
>
{{ pendingChannel === 'MAX' ? 'Открываем Max…' : 'Войти через Max' }}
</button>
</div>
<p v-if="requestCodeMutation.loading.value" class="text-sm text-base-content/70">
Проверяем e-mail и отправляем код...
</p>
</div>
<div v-else class="space-y-4">
<p class="text-sm text-base-content/70">E-mail: {{ maskedEmail }}</p>
<fieldset class="fieldset">
<legend class="fieldset-legend text-base font-semibold">Код</legend>
<input
v-model="code"
type="text"
maxlength="6"
class="input input-bordered w-full"
placeholder="123456"
@keydown.enter.prevent="verifyCode"
>
</fieldset>
<button
class="btn btn-primary w-full"
:disabled="verifyCodeMutation.loading.value"
@click="verifyCode"
>
{{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }}
</button>
<button
class="btn btn-ghost w-full"
@click="step = 'request'; code = ''; feedback = ''; challengeToken = ''; maskedEmail = ''"
>
Изменить e-mail
</button>
<p class="text-xs text-base-content/60">Код действует до {{ expiresAt }}</p>
</div>
<div
v-if="feedback"
class="alert mt-2"
:class="feedbackTone === 'success' ? 'alert-success' : 'alert-error'"
>
{{ feedback }}
</div>
</div>
</div>
</section>
</template>