Files
web-frontend/app/pages/login.vue
2026-04-02 14:49:31 +07:00

216 lines
6.7 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 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>
<input
v-model="email"
type="email"
class="input input-bordered input-primary w-full"
placeholder="name@company.com"
>
<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>
</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>
<input
v-model="code"
type="text"
maxlength="6"
class="input input-bordered input-primary w-full"
placeholder="123456"
>
<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>
<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>