Files
web-frontend/app/pages/login.vue

314 lines
9.0 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 { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink';
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 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 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;
}
async function navigateAfterLogin(user: { company?: { id: string } | null }) {
if (!user.company?.id) {
await navigateTo('/profile');
return;
}
await navigateTo('/products');
}
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 не найден. Вход доступен только для созданных аккаунтов.';
}
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 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);
await navigateAfterLogin(payload.user);
}
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);
}
});
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>
</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">
<a
:href="telegramLoginUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary"
:class="{ 'btn-disabled pointer-events-none': !telegramLoginUrl }"
>
Войти через Telegram
</a>
<a
:href="maxLoginUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-accent"
:class="{ 'btn-disabled pointer-events-none': !maxLoginUrl }"
>
Войти через Max
</a>
</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>