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

260 lines
9.0 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';
type LoginChannel = 'EMAIL' | 'TELEGRAM' | 'MAX';
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 channel = ref<LoginChannel>('EMAIL');
const destination = ref('');
const challengeToken = ref('');
const maskedDestination = ref('');
const expiresAt = ref('');
const code = ref('');
const feedback = ref('');
const feedbackTone = ref<'success' | 'error'>('success');
const userIdForBot = ref('');
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 selectedBotUrl = computed(() =>
channel.value === 'TELEGRAM'
? telegramBotUrl.value
: maxBotUrl.value,
);
const startCommand = computed(() =>
userIdForBot.value.trim() ? `/start ${userIdForBot.value.trim()}` : '/start <ваш_user_id>',
);
async function finalizeSession(accessToken: string) {
authCookie.value = accessToken;
await navigateTo('/products');
}
async function requestCode() {
if (channel.value !== 'EMAIL') {
feedback.value = 'Кодовый вход доступен только для Email.';
feedbackTone.value = 'error';
return;
}
feedback.value = '';
const result = await requestCodeMutation.mutate({
input: {
channel: channel.value,
destination: destination.value.trim(),
},
});
const payload = result?.data?.requestLoginCode;
if (!payload) {
feedback.value = 'Не получилось отправить код.';
feedbackTone.value = 'error';
return;
}
challengeToken.value = payload.challengeToken;
maskedDestination.value = payload.destination;
expiresAt.value = new Date(payload.expiresAt).toLocaleString();
feedback.value = `Код отправлен на ${payload.destination}. Тестовый код сейчас: 123456`;
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);
}
function copyStartCommand() {
navigator.clipboard.writeText(startCommand.value);
feedback.value = `Команда скопирована: ${startCommand.value}`;
feedbackTone.value = 'success';
}
watch(channel, () => {
feedback.value = '';
if (channel.value !== 'EMAIL') {
step.value = 'request';
}
});
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 вход по коду. Telegram/Max вход через бота и временный токен.
</p>
</div>
<div class="mb-5 flex justify-center">
<div class="join">
<button class="btn join-item" :class="{ 'btn-primary': channel === 'EMAIL' }" @click="channel = 'EMAIL'">
Email
</button>
<button class="btn join-item" :class="{ 'btn-primary': channel === 'TELEGRAM' }" @click="channel = 'TELEGRAM'">
Telegram
</button>
<button class="btn join-item" :class="{ 'btn-primary': channel === 'MAX' }" @click="channel = 'MAX'">
Max
</button>
</div>
</div>
<div v-if="channel === 'EMAIL' && 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">На этот адрес отправим код</label>
<input
v-model="destination"
type="email"
class="input input-bordered w-full"
placeholder="name@company.com"
>
</fieldset>
<button
class="btn btn-primary w-full"
:disabled="requestCodeMutation.loading.value"
@click="requestCode"
>
{{ requestCodeMutation.loading.value ? 'Отправляем…' : 'Получить код' }}
</button>
</div>
<div v-else-if="channel === 'EMAIL'" class="space-y-4">
<div class="alert alert-info">
Код отправлен на <span class="font-bold">{{ maskedDestination }}</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-else class="space-y-4">
<div class="alert alert-info">
Откройте {{ channel === 'TELEGRAM' ? 'Telegram' : 'Max' }}-бота, отправьте команду
<span class="font-bold">{{ startCommand }}</span>, затем передайте боту свой <code>user_id</code>.
Бот пришлёт кнопку входа в кабинет с временным токеном.
</div>
<fieldset class="fieldset rounded-box border border-base-300 bg-base-200/40 p-4">
<legend class="fieldset-legend text-sm font-semibold">Ваш user_id</legend>
<label class="label pb-1">Для команды /start в боте</label>
<input
v-model="userIdForBot"
type="text"
class="input input-bordered w-full"
placeholder="например: cm5abc123xyz"
>
</fieldset>
<div class="grid gap-2 sm:grid-cols-2">
<button class="btn btn-outline" @click="copyStartCommand">
Скопировать команду
</button>
<a
:href="selectedBotUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-primary"
:class="{ 'btn-disabled pointer-events-none': !selectedBotUrl }"
>
Открыть бота
</a>
</div>
<p v-if="!selectedBotUrl" class="text-xs text-error">
Ссылка на {{ channel === 'TELEGRAM' ? 'Telegram' : 'Max' }}-бота не настроена в env.
</p>
</div>
<div
v-if="feedback"
class="alert mt-2"
:class="feedbackTone === 'success' ? 'alert-success' : 'alert-error'"
>
{{ feedback }}
</div>
</div>
</div>
</section>
</template>