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

225 lines
7.4 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';
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 requestCodeMutation = useMutation(RequestLoginCodeDocument);
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument);
const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument);
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');
}
async function requestCode() {
if (!isEmailReady.value) {
feedback.value = 'Введите корректный email.';
feedbackTone.value = 'error';
return;
}
feedback.value = '';
const result = await requestCodeMutation.mutate({
input: {
channel: 'EMAIL',
destination: normalizedEmail.value,
},
});
const payload = result?.data?.requestLoginCode;
if (!payload) {
feedback.value = 'Не получилось отправить код.';
feedbackTone.value = 'error';
return;
}
challengeToken.value = payload.challengeToken;
maskedEmail.value = payload.destination;
expiresAt.value = new Date(payload.expiresAt).toLocaleString();
feedback.value = `Код отправлен на ${payload.destination}.`;
feedbackTone.value = 'success';
step.value = 'verify';
}
async function verifyCode() {
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);
}
onMounted(async () => {
const loginToken = typeof route.query.login_token === 'string' ? route.query.login_token : '';
if (loginToken) {
await consumeLoginToken(loginToken);
}
});
</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 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">
Введите email и выберите способ входа.
</p>
</div>
<div v-if="step === 'request'" class="space-y-4">
<fieldset class="fieldset rounded-box border border-base-300 bg-base-200/40 p-4">
<legend class="fieldset-legend text-sm font-semibold">Email адрес</legend>
<label class="label pb-1">На этот email придет код и он же пойдет в deep-link бота</label>
<input
v-model="email"
type="email"
class="input input-bordered w-full"
placeholder="name@company.com"
>
</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>
<button
v-if="isEmailReady"
class="btn btn-primary w-full"
:disabled="requestCodeMutation.loading.value"
@click="requestCode"
>
{{ requestCodeMutation.loading.value ? 'Отправляем…' : 'Получить код' }}
</button>
<p v-if="!telegramBotUrl || !maxBotUrl" class="text-xs text-warning">
Для кнопок бота нужно задать `NUXT_PUBLIC_TELEGRAM_BOT_URL` и `NUXT_PUBLIC_MAX_BOT_URL`.
</p>
</div>
<div v-else class="space-y-4">
<div class="alert alert-info">
Код отправлен на <span class="font-bold">{{ maskedEmail }}</span>.
Действителен до: <span class="font-bold">{{ expiresAt }}</span>.
</div>
<fieldset class="fieldset rounded-box border border-base-300 bg-base-200/40 p-4">
<legend class="fieldset-legend text-sm font-semibold">Код подтверждения</legend>
<label class="label pb-1">Введите 6-значный код</label>
<input
v-model="code"
type="text"
maxlength="6"
class="input input-bordered w-full"
placeholder="123456"
>
</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'">
Вернуться
</button>
</div>
<div
v-if="feedback"
class="alert mt-2"
:class="feedbackTone === 'success' ? 'alert-success' : 'alert-error'"
>
{{ feedback }}
</div>
</div>
</div>
</section>
</template>