Files
web-frontend/app/pages/login.vue
2026-04-01 19:20:58 +07:00

269 lines
9.1 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 channelHint = computed(() => {
return 'Email адрес';
});
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="surface-card w-full rounded-[34px] p-5 md:p-8">
<div class="mb-5 text-center">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Вход в личный кабинет</h1>
<p class="mt-1 text-sm text-[#28543f]/80">
Email вход по коду. Telegram/Max вход через бота и временный токен.
</p>
</div>
<div class="mx-auto mb-5 flex w-full max-w-xl flex-wrap items-center justify-center gap-2">
<button
class="glass-capsule rounded-full px-4 py-2 text-sm font-semibold text-[#123824] transition hover:scale-[1.01]"
:class="{ 'bg-[#139957] text-white': channel === 'EMAIL' }"
@click="channel = 'EMAIL'"
>
Email
</button>
<button
class="glass-capsule rounded-full px-4 py-2 text-sm font-semibold text-[#123824] transition hover:scale-[1.01]"
:class="{ 'bg-[#139957] text-white': channel === 'TELEGRAM' }"
@click="channel = 'TELEGRAM'"
>
Telegram
</button>
<button
class="glass-capsule rounded-full px-4 py-2 text-sm font-semibold text-[#123824] transition hover:scale-[1.01]"
:class="{ 'bg-[#139957] text-white': channel === 'MAX' }"
@click="channel = 'MAX'"
>
Max
</button>
</div>
<div v-if="channel === 'EMAIL' && step === 'request'" class="space-y-3">
<label class="form-control">
<span class="label-text font-semibold text-[#194631]">{{ channelHint }}</span>
<input
v-model="destination"
type="text"
class="input input-bordered border-[#d0e8d8] bg-white/80"
:placeholder="channelHint"
>
</label>
<button
class="btn w-full border-0 bg-[#139957] text-white hover:bg-[#0d854a]"
:disabled="requestCodeMutation.loading.value"
@click="requestCode"
>
{{ requestCodeMutation.loading.value ? 'Отправляем…' : 'Получить код' }}
</button>
</div>
<div v-else-if="channel === 'EMAIL'" class="space-y-3">
<div class="rounded-xl border border-[#d6ebde] bg-white/75 p-3 text-sm text-[#214735]">
Код отправлен на <span class="font-bold">{{ maskedDestination }}</span>.
Действителен до: <span class="font-bold">{{ expiresAt }}</span>.
</div>
<label class="form-control">
<span class="label-text font-semibold text-[#194631]">Код подтверждения</span>
<input
v-model="code"
type="text"
maxlength="6"
class="input input-bordered border-[#d0e8d8] bg-white/80"
placeholder="123456"
>
</label>
<button
class="btn w-full border-0 bg-[#139957] text-white hover:bg-[#0d854a]"
: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="rounded-xl border border-[#d6ebde] bg-white/75 p-3 text-sm text-[#214735]">
Откройте {{ channel === 'TELEGRAM' ? 'Telegram' : 'Max' }}-бота, отправьте команду
<span class="font-bold">{{ startCommand }}</span>, затем передайте боту свой <code>user_id</code>.
Бот пришлёт кнопку входа в кабинет с временным токеном.
</div>
<label class="form-control">
<span class="label-text font-semibold text-[#194631]">Ваш user_id (для команды /start)</span>
<input
v-model="userIdForBot"
type="text"
class="input input-bordered border-[#d0e8d8] bg-white/80"
placeholder="например: cm5abc123xyz"
>
</label>
<div class="grid gap-2 sm:grid-cols-2">
<button class="btn btn-outline border-[#139957] text-[#0d854a]" @click="copyStartCommand">
Скопировать команду
</button>
<a
:href="selectedBotUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn border-0 bg-[#139957] text-white hover:bg-[#0d854a]"
:class="{ 'btn-disabled pointer-events-none': !selectedBotUrl }"
>
Открыть бота
</a>
</div>
<p v-if="!selectedBotUrl" class="text-xs text-[#b42318]">
Ссылка на {{ channel === 'TELEGRAM' ? 'Telegram' : 'Max' }}-бота не настроена в env.
</p>
</div>
<div
v-if="feedback"
class="alert mt-4"
:class="feedbackTone === 'success' ? 'alert-success' : 'alert-error'"
>
{{ feedback }}
</div>
</div>
</section>
</template>