Simplify login screen to email-first with bot entry buttons

This commit is contained in:
Ruslan Bakiev
2026-04-02 14:44:40 +07:00
parent 2b9d816758
commit 8c4280c20b

View File

@@ -6,8 +6,6 @@ import {
VerifyLoginCodeDocument, VerifyLoginCodeDocument,
} from '~/composables/graphql/generated'; } from '~/composables/graphql/generated';
type LoginChannel = 'EMAIL' | 'TELEGRAM' | 'MAX';
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const route = useRoute(); const route = useRoute();
const authCookieName = config.public.authCookieName || 'fregat_auth_token'; const authCookieName = config.public.authCookieName || 'fregat_auth_token';
@@ -17,15 +15,13 @@ const authCookie = useCookie<string | null>(authCookieName, {
}); });
const step = ref<'request' | 'verify'>('request'); const step = ref<'request' | 'verify'>('request');
const channel = ref<LoginChannel>('EMAIL'); const email = ref('');
const destination = ref('');
const challengeToken = ref(''); const challengeToken = ref('');
const maskedDestination = ref(''); const maskedEmail = ref('');
const expiresAt = ref(''); 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 userIdForBot = ref('');
const requestCodeMutation = useMutation(RequestLoginCodeDocument); const requestCodeMutation = useMutation(RequestLoginCodeDocument);
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument); const verifyCodeMutation = useMutation(VerifyLoginCodeDocument);
@@ -33,14 +29,22 @@ const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument);
const telegramBotUrl = computed(() => config.public.telegramBotUrl || ''); const telegramBotUrl = computed(() => config.public.telegramBotUrl || '');
const maxBotUrl = computed(() => config.public.maxBotUrl || ''); const maxBotUrl = computed(() => config.public.maxBotUrl || '');
const selectedBotUrl = computed(() =>
channel.value === 'TELEGRAM' const normalizedEmail = computed(() => email.value.trim().toLowerCase());
? telegramBotUrl.value const isEmailReady = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail.value));
: maxBotUrl.value,
); function buildBotLoginUrl(baseUrl: string) {
const startCommand = computed(() => if (!isEmailReady.value || !baseUrl) {
userIdForBot.value.trim() ? `/start ${userIdForBot.value.trim()}` : '/start <ваш_user_id>', 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) { async function finalizeSession(accessToken: string) {
authCookie.value = accessToken; authCookie.value = accessToken;
@@ -48,8 +52,8 @@ async function finalizeSession(accessToken: string) {
} }
async function requestCode() { async function requestCode() {
if (channel.value !== 'EMAIL') { if (!isEmailReady.value) {
feedback.value = 'Кодовый вход доступен только для Email.'; feedback.value = 'Введите корректный email.';
feedbackTone.value = 'error'; feedbackTone.value = 'error';
return; return;
} }
@@ -57,8 +61,8 @@ async function requestCode() {
feedback.value = ''; feedback.value = '';
const result = await requestCodeMutation.mutate({ const result = await requestCodeMutation.mutate({
input: { input: {
channel: channel.value, channel: 'EMAIL',
destination: destination.value.trim(), destination: normalizedEmail.value,
}, },
}); });
@@ -70,9 +74,9 @@ async function requestCode() {
} }
challengeToken.value = payload.challengeToken; challengeToken.value = payload.challengeToken;
maskedDestination.value = payload.destination; maskedEmail.value = payload.destination;
expiresAt.value = new Date(payload.expiresAt).toLocaleString(); expiresAt.value = new Date(payload.expiresAt).toLocaleString();
feedback.value = `Код отправлен на ${payload.destination}. Тестовый код сейчас: 123456`; feedback.value = `Код отправлен на ${payload.destination}.`;
feedbackTone.value = 'success'; feedbackTone.value = 'success';
step.value = 'verify'; step.value = 'verify';
} }
@@ -110,19 +114,6 @@ async function consumeLoginToken(loginToken: string) {
await finalizeSession(payload.accessToken); 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 () => { 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) {
@@ -138,48 +129,60 @@ onMounted(async () => {
<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"> <p class="mt-1 text-sm text-base-content/70">
Email вход по коду. Telegram/Max вход через бота и временный токен. Введите email и выберите способ входа.
</p> </p>
</div> </div>
<div class="mb-5 flex justify-center"> <div v-if="step === 'request'" class="space-y-4">
<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"> <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> <legend class="fieldset-legend text-sm font-semibold">Email адрес</legend>
<label class="label pb-1">На этот адрес отправим код</label> <label class="label pb-1">На этот email придет код и он же пойдет в deep-link бота</label>
<input <input
v-model="destination" v-model="email"
type="email" type="email"
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder="name@company.com" placeholder="name@company.com"
> >
</fieldset> </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 <button
v-if="isEmailReady"
class="btn btn-primary w-full" class="btn btn-primary w-full"
:disabled="requestCodeMutation.loading.value" :disabled="requestCodeMutation.loading.value"
@click="requestCode" @click="requestCode"
> >
{{ requestCodeMutation.loading.value ? 'Отправляем…' : 'Получить код' }} {{ requestCodeMutation.loading.value ? 'Отправляем…' : 'Получить код' }}
</button> </button>
<p v-if="!telegramBotUrl || !maxBotUrl" class="text-xs text-warning">
Для кнопок бота нужно задать `NUXT_PUBLIC_TELEGRAM_BOT_URL` и `NUXT_PUBLIC_MAX_BOT_URL`.
</p>
</div> </div>
<div v-else-if="channel === 'EMAIL'" class="space-y-4"> <div v-else class="space-y-4">
<div class="alert alert-info"> <div class="alert alert-info">
Код отправлен на <span class="font-bold">{{ maskedDestination }}</span>. Код отправлен на <span class="font-bold">{{ maskedEmail }}</span>.
Действителен до: <span class="font-bold">{{ expiresAt }}</span>. Действителен до: <span class="font-bold">{{ expiresAt }}</span>.
</div> </div>
@@ -204,48 +207,10 @@ onMounted(async () => {
</button> </button>
<button class="btn btn-ghost w-full" @click="step = 'request'"> <button class="btn btn-ghost w-full" @click="step = 'request'">
Выбрать другой канал Вернуться
</button> </button>
</div> </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 <div
v-if="feedback" v-if="feedback"
class="alert mt-2" class="alert mt-2"