Switch Telegram/Max login to bot temporary token flow
This commit is contained in:
@@ -139,6 +139,7 @@ export type Mutation = {
|
|||||||
clientReviewOrder: Order;
|
clientReviewOrder: Order;
|
||||||
completeOrder: Order;
|
completeOrder: Order;
|
||||||
connectMessenger: MessengerConnection;
|
connectMessenger: MessengerConnection;
|
||||||
|
consumeLoginToken: AuthSession;
|
||||||
createInvitation: Invitation;
|
createInvitation: Invitation;
|
||||||
createReferral: ReferralLink;
|
createReferral: ReferralLink;
|
||||||
managerFinalizeOrder: Order;
|
managerFinalizeOrder: Order;
|
||||||
@@ -187,6 +188,11 @@ export type MutationConnectMessengerArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationConsumeLoginTokenArgs = {
|
||||||
|
token: Scalars['String']['input'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationCreateInvitationArgs = {
|
export type MutationCreateInvitationArgs = {
|
||||||
input: CreateInvitationInput;
|
input: CreateInvitationInput;
|
||||||
};
|
};
|
||||||
@@ -510,6 +516,13 @@ export enum WithdrawalStatus {
|
|||||||
Rejected = 'REJECTED'
|
Rejected = 'REJECTED'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConsumeLoginTokenMutationVariables = Exact<{
|
||||||
|
token: Scalars['String']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type ConsumeLoginTokenMutation = { __typename?: 'Mutation', consumeLoginToken: { __typename?: 'AuthSession', accessToken: string, expiresAt: any, user: { __typename?: 'User', id: string, email: string, fullName: string, role: UserRole } } };
|
||||||
|
|
||||||
export type RegisterSelfMutationVariables = Exact<{
|
export type RegisterSelfMutationVariables = Exact<{
|
||||||
input: RegisterSelfInput;
|
input: RegisterSelfInput;
|
||||||
}>;
|
}>;
|
||||||
@@ -598,6 +611,42 @@ export type ConnectMessengerMutationVariables = Exact<{
|
|||||||
export type ConnectMessengerMutation = { __typename?: 'Mutation', connectMessenger: { __typename?: 'MessengerConnection', id: string, type: MessengerType, channelId: string, isActive: boolean } };
|
export type ConnectMessengerMutation = { __typename?: 'Mutation', connectMessenger: { __typename?: 'MessengerConnection', id: string, type: MessengerType, channelId: string, isActive: boolean } };
|
||||||
|
|
||||||
|
|
||||||
|
export const ConsumeLoginTokenDocument = gql`
|
||||||
|
mutation ConsumeLoginToken($token: String!) {
|
||||||
|
consumeLoginToken(token: $token) {
|
||||||
|
accessToken
|
||||||
|
expiresAt
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
fullName
|
||||||
|
role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useConsumeLoginTokenMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useConsumeLoginTokenMutation` within a Vue component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useConsumeLoginTokenMutation` returns an object that includes:
|
||||||
|
* - A mutate function that you can call at any time to execute the mutation
|
||||||
|
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
|
||||||
|
*
|
||||||
|
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { mutate, loading, error, onDone } = useConsumeLoginTokenMutation({
|
||||||
|
* variables: {
|
||||||
|
* token: // value for 'token'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useConsumeLoginTokenMutation(options: VueApolloComposable.UseMutationOptions<ConsumeLoginTokenMutation, ConsumeLoginTokenMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<ConsumeLoginTokenMutation, ConsumeLoginTokenMutationVariables>> = {}) {
|
||||||
|
return VueApolloComposable.useMutation<ConsumeLoginTokenMutation, ConsumeLoginTokenMutationVariables>(ConsumeLoginTokenDocument, options);
|
||||||
|
}
|
||||||
|
export type ConsumeLoginTokenMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<ConsumeLoginTokenMutation, ConsumeLoginTokenMutationVariables>;
|
||||||
export const RegisterSelfDocument = gql`
|
export const RegisterSelfDocument = gql`
|
||||||
mutation RegisterSelf($input: RegisterSelfInput!) {
|
mutation RegisterSelf($input: RegisterSelfInput!) {
|
||||||
registerSelf(input: $input) {
|
registerSelf(input: $input) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMutation } from '@vue/apollo-composable';
|
import { useMutation } from '@vue/apollo-composable';
|
||||||
import {
|
import {
|
||||||
|
ConsumeLoginTokenDocument,
|
||||||
RequestLoginCodeDocument,
|
RequestLoginCodeDocument,
|
||||||
VerifyLoginCodeDocument,
|
VerifyLoginCodeDocument,
|
||||||
} from '~/composables/graphql/generated';
|
} from '~/composables/graphql/generated';
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
type LoginChannel = 'EMAIL' | 'TELEGRAM' | 'MAX';
|
type LoginChannel = 'EMAIL' | 'TELEGRAM' | 'MAX';
|
||||||
|
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
|
const route = useRoute();
|
||||||
const authCookieName = config.public.authCookieName || 'fregat_auth_token';
|
const authCookieName = config.public.authCookieName || 'fregat_auth_token';
|
||||||
const authCookie = useCookie<string | null>(authCookieName, {
|
const authCookie = useCookie<string | null>(authCookieName, {
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
@@ -23,21 +25,39 @@ 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);
|
||||||
|
const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument);
|
||||||
|
|
||||||
const channelHint = computed(() => {
|
const channelHint = computed(() => {
|
||||||
if (channel.value === 'EMAIL') {
|
return 'Email адрес';
|
||||||
return 'Email адрес';
|
|
||||||
}
|
|
||||||
if (channel.value === 'TELEGRAM') {
|
|
||||||
return 'Telegram channel id';
|
|
||||||
}
|
|
||||||
return 'Max channel id';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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() {
|
async function requestCode() {
|
||||||
|
if (channel.value !== 'EMAIL') {
|
||||||
|
feedback.value = 'Кодовый вход доступен только для Email.';
|
||||||
|
feedbackTone.value = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
feedback.value = '';
|
feedback.value = '';
|
||||||
const result = await requestCodeMutation.mutate({
|
const result = await requestCodeMutation.mutate({
|
||||||
input: {
|
input: {
|
||||||
@@ -77,9 +97,42 @@ async function verifyCode() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
authCookie.value = payload.accessToken;
|
await finalizeSession(payload.accessToken);
|
||||||
await navigateTo('/products');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -88,7 +141,7 @@ async function verifyCode() {
|
|||||||
<div class="mb-5 text-center">
|
<div class="mb-5 text-center">
|
||||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Вход в личный кабинет</h1>
|
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Вход в личный кабинет</h1>
|
||||||
<p class="mt-1 text-sm text-[#28543f]/80">
|
<p class="mt-1 text-sm text-[#28543f]/80">
|
||||||
Получите одноразовый код и подтвердите вход.
|
Email вход по коду. Telegram/Max вход через бота и временный токен.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,7 +169,7 @@ async function verifyCode() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="step === 'request'" class="space-y-3">
|
<div v-if="channel === 'EMAIL' && step === 'request'" class="space-y-3">
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<span class="label-text font-semibold text-[#194631]">{{ channelHint }}</span>
|
<span class="label-text font-semibold text-[#194631]">{{ channelHint }}</span>
|
||||||
<input
|
<input
|
||||||
@@ -136,7 +189,7 @@ async function verifyCode() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<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]">
|
<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">{{ maskedDestination }}</span>.
|
||||||
Действителен до: <span class="font-bold">{{ expiresAt }}</span>.
|
Действителен до: <span class="font-bold">{{ expiresAt }}</span>.
|
||||||
@@ -166,6 +219,43 @@ async function verifyCode() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-if="feedback"
|
v-if="feedback"
|
||||||
class="alert mt-4"
|
class="alert mt-4"
|
||||||
|
|||||||
12
graphql/operations/auth/consume-login-token.graphql
Normal file
12
graphql/operations/auth/consume-login-token.graphql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
mutation ConsumeLoginToken($token: String!) {
|
||||||
|
consumeLoginToken(token: $token) {
|
||||||
|
accessToken
|
||||||
|
expiresAt
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
fullName
|
||||||
|
role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -323,6 +323,7 @@ input ReviewRewardWithdrawalInput {
|
|||||||
type Mutation {
|
type Mutation {
|
||||||
requestLoginCode(input: RequestLoginCodeInput!): AuthCodeRequestResult!
|
requestLoginCode(input: RequestLoginCodeInput!): AuthCodeRequestResult!
|
||||||
verifyLoginCode(input: VerifyLoginCodeInput!): AuthSession!
|
verifyLoginCode(input: VerifyLoginCodeInput!): AuthSession!
|
||||||
|
consumeLoginToken(token: String!): AuthSession!
|
||||||
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
|
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
|
||||||
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
|
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
|
||||||
createInvitation(input: CreateInvitationInput!): Invitation!
|
createInvitation(input: CreateInvitationInput!): Invitation!
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export default defineNuxtConfig({
|
|||||||
authCookieName,
|
authCookieName,
|
||||||
public: {
|
public: {
|
||||||
authCookieName,
|
authCookieName,
|
||||||
|
telegramBotUrl: process.env.NUXT_PUBLIC_TELEGRAM_BOT_URL ?? '',
|
||||||
|
maxBotUrl: process.env.NUXT_PUBLIC_MAX_BOT_URL ?? '',
|
||||||
sentryDsn: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
|
sentryDsn: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
|
||||||
sentryEnvironment: process.env.NUXT_PUBLIC_SENTRY_ENVIRONMENT ?? 'development',
|
sentryEnvironment: process.env.NUXT_PUBLIC_SENTRY_ENVIRONMENT ?? 'development',
|
||||||
sentryRelease: process.env.NUXT_PUBLIC_SENTRY_RELEASE ?? 'dev',
|
sentryRelease: process.env.NUXT_PUBLIC_SENTRY_RELEASE ?? 'dev',
|
||||||
|
|||||||
Reference in New Issue
Block a user