Fix login UX auto-code flow and input border visibility

This commit is contained in:
Ruslan Bakiev
2026-04-02 15:24:28 +07:00
parent 8f143480fd
commit 87c30447a6
2 changed files with 131 additions and 38 deletions

View File

@@ -40,7 +40,7 @@
--radius-box: 2rem;
--size-selector: 0.3125rem;
--size-field: 0.3125rem;
--border: 0.5px;
--border: 1px;
--depth: 0;
--noise: 1;
}

View File

@@ -22,10 +22,12 @@ 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);
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument);
const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument);
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 || '');
@@ -51,12 +53,50 @@ async function finalizeSession(accessToken: string) {
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({
@@ -68,20 +108,28 @@ async function requestCode() {
const payload = result?.data?.requestLoginCode;
if (!payload) {
feedback.value = 'Не получилось отправить код.';
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: {
@@ -114,33 +162,79 @@ async function consumeLoginToken(loginToken: string) {
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 bg-base-100 shadow-xl">
<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 class="mt-1 text-sm text-base-content/70">Вход через e-mail</p>
</div>
<div v-if="step === 'request'" class="space-y-4">
<label class="label">
<span class="label-text font-semibold">E-mail</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-base font-semibold">E-mail</legend>
<input
v-model="email"
type="email"
class="input input-bordered input-primary w-full"
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
@@ -163,29 +257,25 @@ onMounted(async () => {
</a>
</div>
<button
v-if="isEmailReady"
class="btn btn-primary w-full"
:disabled="requestCodeMutation.loading.value"
@click="requestCode"
>
{{ requestCodeMutation.loading.value ? 'Отправляем…' : 'Получить код' }}
</button>
<p v-if="requestCodeMutation.loading.value" class="text-sm text-base-content/70">
Проверяем e-mail и отправляем код...
</p>
</div>
<div v-else class="space-y-4">
<h2 class="text-xl font-semibold">E-mail</h2>
<p class="text-sm text-base-content/70">{{ maskedEmail }}</p>
<label class="label">
<span class="label-text font-semibold">Код</span>
</label>
<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 input-primary w-full"
class="input w-full"
placeholder="123456"
@keydown.enter.prevent="verifyCode"
>
</fieldset>
<button
class="btn btn-primary w-full"
@@ -195,8 +285,11 @@ onMounted(async () => {
{{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }}
</button>
<button class="btn btn-ghost w-full" @click="step = 'request'">
Вернуться
<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>