309 lines
8.8 KiB
Vue
309 lines
8.8 KiB
Vue
<script setup lang="ts">
|
||
import { useMutation } from '@vue/apollo-composable';
|
||
import {
|
||
ConsumeLoginTokenDocument,
|
||
RequestLoginCodeDocument,
|
||
VerifyLoginCodeDocument,
|
||
} from '~/composables/graphql/generated';
|
||
|
||
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));
|
||
|
||
function buildBotLoginUrl(baseUrl: string) {
|
||
if (!isEmailReady.value || !baseUrl) {
|
||
return '';
|
||
}
|
||
|
||
const payload = encodeURIComponent(`login:${normalizedEmail.value}`);
|
||
const separator = baseUrl.includes('?') ? '&' : '?';
|
||
return `${baseUrl}${separator}start=${payload}`;
|
||
}
|
||
|
||
const telegramLoginUrl = computed(() => buildBotLoginUrl(telegramBotUrl.value));
|
||
const maxLoginUrl = computed(() => buildBotLoginUrl(maxBotUrl.value));
|
||
|
||
async function finalizeSession(accessToken: string) {
|
||
authCookie.value = accessToken;
|
||
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);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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 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 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>
|