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; --radius-box: 2rem;
--size-selector: 0.3125rem; --size-selector: 0.3125rem;
--size-field: 0.3125rem; --size-field: 0.3125rem;
--border: 0.5px; --border: 1px;
--depth: 0; --depth: 0;
--noise: 1; --noise: 1;
} }

View File

@@ -22,10 +22,12 @@ const expiresAt = ref('');
const code = ref(''); const code = ref('');
const feedback = ref(''); const feedback = ref('');
const feedbackTone = ref<'success' | 'error'>('success'); const feedbackTone = ref<'success' | 'error'>('success');
const autoRequestTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const lastRequestedEmail = ref('');
const requestCodeMutation = useMutation(RequestLoginCodeDocument); const requestCodeMutation = useMutation(RequestLoginCodeDocument, { throws: 'never' });
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument); const verifyCodeMutation = useMutation(VerifyLoginCodeDocument, { throws: 'never' });
const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument); const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument, { throws: 'never' });
const telegramBotUrl = computed(() => config.public.telegramBotUrl || ''); const telegramBotUrl = computed(() => config.public.telegramBotUrl || '');
const maxBotUrl = computed(() => config.public.maxBotUrl || ''); const maxBotUrl = computed(() => config.public.maxBotUrl || '');
@@ -51,12 +53,50 @@ async function finalizeSession(accessToken: string) {
await navigateTo('/products'); 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() { async function requestCode() {
if (!isEmailReady.value) { if (!isEmailReady.value) {
feedback.value = 'Введите корректный email.'; feedback.value = 'Введите корректный email.';
feedbackTone.value = 'error'; feedbackTone.value = 'error';
return; return;
} }
if (requestCodeMutation.loading.value) {
return;
}
clearAutoRequestTimer();
feedback.value = ''; feedback.value = '';
const result = await requestCodeMutation.mutate({ const result = await requestCodeMutation.mutate({
@@ -68,20 +108,28 @@ async function requestCode() {
const payload = result?.data?.requestLoginCode; const payload = result?.data?.requestLoginCode;
if (!payload) { if (!payload) {
feedback.value = 'Не получилось отправить код.'; feedback.value = requestCodeFailedMessage();
feedbackTone.value = 'error'; feedbackTone.value = 'error';
return; return;
} }
lastRequestedEmail.value = normalizedEmail.value;
challengeToken.value = payload.challengeToken; challengeToken.value = payload.challengeToken;
maskedEmail.value = payload.destination; maskedEmail.value = payload.destination;
expiresAt.value = new Date(payload.expiresAt).toLocaleString(); expiresAt.value = new Date(payload.expiresAt).toLocaleString();
code.value = '';
feedback.value = `Код отправлен на ${payload.destination}.`; feedback.value = `Код отправлен на ${payload.destination}.`;
feedbackTone.value = 'success'; feedbackTone.value = 'success';
step.value = 'verify'; step.value = 'verify';
} }
async function verifyCode() { async function verifyCode() {
if (!challengeToken.value || !code.value.trim()) {
feedback.value = 'Введите код из письма.';
feedbackTone.value = 'error';
return;
}
feedback.value = ''; feedback.value = '';
const result = await verifyCodeMutation.mutate({ const result = await verifyCodeMutation.mutate({
input: { input: {
@@ -114,33 +162,79 @@ async function consumeLoginToken(loginToken: string) {
await finalizeSession(payload.accessToken); 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 () => { onMounted(async () => {
const loginToken = typeof route.query.login_token === 'string' ? route.query.login_token : ''; const loginToken = typeof route.query.login_token === 'string' ? route.query.login_token : '';
if (loginToken) { if (loginToken) {
await consumeLoginToken(loginToken); await consumeLoginToken(loginToken);
} }
}); });
onBeforeUnmount(() => {
clearAutoRequestTimer();
});
</script> </script>
<template> <template>
<section class="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-3xl items-center py-8"> <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="card-body p-5 md:p-8">
<div class="mb-4 text-center"> <div class="mb-4 text-center">
<h1 class="text-3xl font-extrabold">Вход в личный кабинет</h1> <h1 class="text-3xl font-extrabold">Вход в личный кабинет</h1>
<p class="mt-1 text-sm text-base-content/70">Вход через e-mail</p>
</div> </div>
<div v-if="step === 'request'" class="space-y-4"> <div v-if="step === 'request'" class="space-y-4">
<label class="label"> <fieldset class="fieldset">
<span class="label-text font-semibold">E-mail</span> <legend class="fieldset-legend text-base font-semibold">E-mail</legend>
</label> <input
<input v-model="email"
v-model="email" type="email"
type="email" class="input w-full"
class="input input-bordered input-primary w-full" placeholder="name@company.com"
placeholder="name@company.com" @keydown.enter.prevent="requestCode"
> @blur="onEmailBlur"
>
</fieldset>
<div class="grid gap-2 sm:grid-cols-2"> <div class="grid gap-2 sm:grid-cols-2">
<a <a
@@ -163,29 +257,25 @@ onMounted(async () => {
</a> </a>
</div> </div>
<button <p v-if="requestCodeMutation.loading.value" class="text-sm text-base-content/70">
v-if="isEmailReady" Проверяем e-mail и отправляем код...
class="btn btn-primary w-full" </p>
:disabled="requestCodeMutation.loading.value"
@click="requestCode"
>
{{ requestCodeMutation.loading.value ? 'Отправляем…' : 'Получить код' }}
</button>
</div> </div>
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<h2 class="text-xl font-semibold">E-mail</h2> <p class="text-sm text-base-content/70">E-mail: {{ maskedEmail }}</p>
<p class="text-sm text-base-content/70">{{ maskedEmail }}</p>
<label class="label"> <fieldset class="fieldset">
<span class="label-text font-semibold">Код</span> <legend class="fieldset-legend text-base font-semibold">Код</legend>
</label> <input
<input v-model="code"
v-model="code" type="text"
type="text" maxlength="6"
maxlength="6" class="input w-full"
class="input input-bordered input-primary w-full" placeholder="123456"
placeholder="123456" @keydown.enter.prevent="verifyCode"
> >
</fieldset>
<button <button
class="btn btn-primary w-full" class="btn btn-primary w-full"
@@ -195,8 +285,11 @@ onMounted(async () => {
{{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }} {{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }}
</button> </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> </button>
<p class="text-xs text-base-content/60">Код действует до {{ expiresAt }}</p> <p class="text-xs text-base-content/60">Код действует до {{ expiresAt }}</p>