Add OTP login page and auth guard for client cabinet

This commit is contained in:
Ruslan Bakiev
2026-04-01 19:10:18 +07:00
parent b4537c1483
commit 1c4fd847dc
9 changed files with 605 additions and 1 deletions

View File

@@ -1,6 +1,11 @@
<script setup lang="ts">
const route = useRoute();
const isLoginPage = computed(() => route.path === '/login');
</script>
<template>
<div class="lk-shell">
<AppHeader />
<AppHeader v-if="!isLoginPage" />
<main class="mx-auto w-full max-w-7xl p-4 md:p-6 lg:p-8">
<NuxtPage />
</main>

View File

@@ -32,6 +32,21 @@ export type AddBonusTransactionInput = {
userId: Scalars['ID']['input'];
};
export type AuthCodeRequestResult = {
__typename?: 'AuthCodeRequestResult';
challengeToken: Scalars['String']['output'];
channel: LoginChannel;
destination: Scalars['String']['output'];
expiresAt: Scalars['DateTime']['output'];
};
export type AuthSession = {
__typename?: 'AuthSession';
accessToken: Scalars['String']['output'];
expiresAt: Scalars['DateTime']['output'];
user: User;
};
export type BlockOrderInput = {
orderId: Scalars['ID']['input'];
reason: Scalars['String']['input'];
@@ -87,6 +102,12 @@ export type Invitation = {
token: Scalars['String']['output'];
};
export enum LoginChannel {
Email = 'EMAIL',
Max = 'MAX',
Telegram = 'TELEGRAM'
}
export type MessengerConnection = {
__typename?: 'MessengerConnection';
channelId: Scalars['String']['output'];
@@ -96,6 +117,15 @@ export type MessengerConnection = {
userId: Scalars['ID']['output'];
};
export type MessengerDispatchResult = {
__typename?: 'MessengerDispatchResult';
channelId: Scalars['String']['output'];
detail: Scalars['String']['output'];
sentAt: Scalars['DateTime']['output'];
success: Scalars['Boolean']['output'];
type: MessengerType;
};
export enum MessengerType {
Max = 'MAX',
Telegram = 'TELEGRAM'
@@ -114,12 +144,15 @@ export type Mutation = {
managerFinalizeOrder: Order;
managerSetOrderOffer: Order;
registerSelf: RegistrationRequest;
requestLoginCode: AuthCodeRequestResult;
requestRewardWithdrawal: RewardWithdrawalRequest;
reviewRegistrationRequest: RegistrationRequest;
reviewRewardWithdrawal: RewardWithdrawalRequest;
sendTestMessengerMessage: MessengerDispatchResult;
startOrderWork: Order;
submitCalculationOrder: Order;
submitReadyOrder: Order;
verifyLoginCode: AuthSession;
};
@@ -180,6 +213,11 @@ export type MutationRegisterSelfArgs = {
};
export type MutationRequestLoginCodeArgs = {
input: RequestLoginCodeInput;
};
export type MutationRequestRewardWithdrawalArgs = {
input: RequestRewardWithdrawalInput;
};
@@ -195,6 +233,13 @@ export type MutationReviewRewardWithdrawalArgs = {
};
export type MutationSendTestMessengerMessageArgs = {
channelId?: InputMaybe<Scalars['String']['input']>;
message?: InputMaybe<Scalars['String']['input']>;
type: MessengerType;
};
export type MutationStartOrderWorkArgs = {
orderId: Scalars['ID']['input'];
};
@@ -209,6 +254,21 @@ export type MutationSubmitReadyOrderArgs = {
input: SubmitReadyOrderInput;
};
export type MutationVerifyLoginCodeArgs = {
input: VerifyLoginCodeInput;
};
export type NotificationHistoryItem = {
__typename?: 'NotificationHistoryItem';
channel: MessengerType;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
message: Scalars['String']['output'];
orderId?: Maybe<Scalars['ID']['output']>;
title: Scalars['String']['output'];
};
export type Order = {
__typename?: 'Order';
blockReason?: Maybe<Scalars['String']['output']>;
@@ -285,20 +345,36 @@ export type Query = {
__typename?: 'Query';
clientProducts: Array<Product>;
healthcheck: Scalars['String']['output'];
managerNotificationHistory: Array<NotificationHistoryItem>;
managerOrders: Array<Order>;
me?: Maybe<User>;
myCurrentOrders: Array<Order>;
myMessengerConnections: Array<MessengerConnection>;
myNotificationHistory: Array<NotificationHistoryItem>;
myOrders: Array<Order>;
referralStats: ReferralStats;
registrationRequests: Array<RegistrationRequest>;
};
export type QueryManagerNotificationHistoryArgs = {
channel: MessengerType;
limit?: InputMaybe<Scalars['Int']['input']>;
userId: Scalars['ID']['input'];
};
export type QueryManagerOrdersArgs = {
status?: InputMaybe<OrderStatus>;
};
export type QueryMyNotificationHistoryArgs = {
channel: MessengerType;
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type QueryRegistrationRequestsArgs = {
status?: InputMaybe<RegistrationStatus>;
};
@@ -352,6 +428,11 @@ export enum RegistrationStatus {
Rejected = 'REJECTED'
}
export type RequestLoginCodeInput = {
channel: LoginChannel;
destination: Scalars['String']['input'];
};
export type RequestRewardWithdrawalInput = {
amount: Scalars['Float']['input'];
};
@@ -411,6 +492,11 @@ export enum UserRole {
Manager = 'MANAGER'
}
export type VerifyLoginCodeInput = {
challengeToken: Scalars['String']['input'];
code: Scalars['String']['input'];
};
export type Warehouse = {
__typename?: 'Warehouse';
code: Scalars['String']['output'];
@@ -431,11 +517,55 @@ export type RegisterSelfMutationVariables = Exact<{
export type RegisterSelfMutation = { __typename?: 'Mutation', registerSelf: { __typename?: 'RegistrationRequest', id: string, companyName: string, contactName: string, email: string, status: RegistrationStatus, createdAt: any } };
export type RequestLoginCodeMutationVariables = Exact<{
input: RequestLoginCodeInput;
}>;
export type RequestLoginCodeMutation = { __typename?: 'Mutation', requestLoginCode: { __typename?: 'AuthCodeRequestResult', challengeToken: string, channel: LoginChannel, destination: string, expiresAt: any } };
export type VerifyLoginCodeMutationVariables = Exact<{
input: VerifyLoginCodeInput;
}>;
export type VerifyLoginCodeMutation = { __typename?: 'Mutation', verifyLoginCode: { __typename?: 'AuthSession', accessToken: string, expiresAt: any, user: { __typename?: 'User', id: string, email: string, fullName: string, role: UserRole } } };
export type ClientProductsQueryVariables = Exact<{ [key: string]: never; }>;
export type ClientProductsQuery = { __typename?: 'Query', clientProducts: Array<{ __typename?: 'Product', id: string, sku: string, name: string, description?: string | null, isCustomizable: boolean, availableInWarehouses: Array<{ __typename?: 'ProductWarehouseBalance', availableQty: number, warehouse: { __typename?: 'Warehouse', id: string, code: string, name: string } }> }> };
export type MyMessengerConnectionsQueryVariables = Exact<{ [key: string]: never; }>;
export type MyMessengerConnectionsQuery = { __typename?: 'Query', myMessengerConnections: Array<{ __typename?: 'MessengerConnection', id: string, type: MessengerType, channelId: string, isActive: boolean }> };
export type MyNotificationHistoryQueryVariables = Exact<{
channel: MessengerType;
limit?: InputMaybe<Scalars['Int']['input']>;
}>;
export type MyNotificationHistoryQuery = { __typename?: 'Query', myNotificationHistory: Array<{ __typename?: 'NotificationHistoryItem', id: string, channel: MessengerType, title: string, message: string, createdAt: any, orderId?: string | null }> };
export type SendTestMessengerMessageMutationVariables = Exact<{
type: MessengerType;
channelId?: InputMaybe<Scalars['String']['input']>;
message?: InputMaybe<Scalars['String']['input']>;
}>;
export type SendTestMessengerMessageMutation = { __typename?: 'Mutation', sendTestMessengerMessage: { __typename?: 'MessengerDispatchResult', type: MessengerType, channelId: string, success: boolean, detail: string, sentAt: any } };
export type ClientReviewOrderMutationVariables = Exact<{
orderId: Scalars['ID']['input'];
decision: Decision;
}>;
export type ClientReviewOrderMutation = { __typename?: 'Mutation', clientReviewOrder: { __typename?: 'Order', id: string, status: OrderStatus, clientApproved?: boolean | null, managerApproved?: boolean | null } };
export type MyCurrentOrdersQueryVariables = Exact<{ [key: string]: never; }>;
@@ -502,6 +632,74 @@ export function useRegisterSelfMutation(options: VueApolloComposable.UseMutation
return VueApolloComposable.useMutation<RegisterSelfMutation, RegisterSelfMutationVariables>(RegisterSelfDocument, options);
}
export type RegisterSelfMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<RegisterSelfMutation, RegisterSelfMutationVariables>;
export const RequestLoginCodeDocument = gql`
mutation RequestLoginCode($input: RequestLoginCodeInput!) {
requestLoginCode(input: $input) {
challengeToken
channel
destination
expiresAt
}
}
`;
/**
* __useRequestLoginCodeMutation__
*
* To run a mutation, you first call `useRequestLoginCodeMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useRequestLoginCodeMutation` 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 } = useRequestLoginCodeMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useRequestLoginCodeMutation(options: VueApolloComposable.UseMutationOptions<RequestLoginCodeMutation, RequestLoginCodeMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<RequestLoginCodeMutation, RequestLoginCodeMutationVariables>> = {}) {
return VueApolloComposable.useMutation<RequestLoginCodeMutation, RequestLoginCodeMutationVariables>(RequestLoginCodeDocument, options);
}
export type RequestLoginCodeMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<RequestLoginCodeMutation, RequestLoginCodeMutationVariables>;
export const VerifyLoginCodeDocument = gql`
mutation VerifyLoginCode($input: VerifyLoginCodeInput!) {
verifyLoginCode(input: $input) {
accessToken
expiresAt
user {
id
email
fullName
role
}
}
}
`;
/**
* __useVerifyLoginCodeMutation__
*
* To run a mutation, you first call `useVerifyLoginCodeMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useVerifyLoginCodeMutation` 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 } = useVerifyLoginCodeMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useVerifyLoginCodeMutation(options: VueApolloComposable.UseMutationOptions<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>> = {}) {
return VueApolloComposable.useMutation<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>(VerifyLoginCodeDocument, options);
}
export type VerifyLoginCodeMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>;
export const ClientProductsDocument = gql`
query ClientProducts {
clientProducts {
@@ -541,6 +739,140 @@ export function useClientProductsLazyQuery(options: VueApolloComposable.UseQuery
return VueApolloComposable.useLazyQuery<ClientProductsQuery, ClientProductsQueryVariables>(ClientProductsDocument, {}, options);
}
export type ClientProductsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<ClientProductsQuery, ClientProductsQueryVariables>;
export const MyMessengerConnectionsDocument = gql`
query MyMessengerConnections {
myMessengerConnections {
id
type
channelId
isActive
}
}
`;
/**
* __useMyMessengerConnectionsQuery__
*
* To run a query within a Vue component, call `useMyMessengerConnectionsQuery` and pass it any options that fit your needs.
* When your component renders, `useMyMessengerConnectionsQuery` returns an object from Apollo Client that contains result, loading and error properties
* you can use to render your UI.
*
* @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
*
* @example
* const { result, loading, error } = useMyMessengerConnectionsQuery();
*/
export function useMyMessengerConnectionsQuery(options: VueApolloComposable.UseQueryOptions<MyMessengerConnectionsQuery, MyMessengerConnectionsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<MyMessengerConnectionsQuery, MyMessengerConnectionsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<MyMessengerConnectionsQuery, MyMessengerConnectionsQueryVariables>> = {}) {
return VueApolloComposable.useQuery<MyMessengerConnectionsQuery, MyMessengerConnectionsQueryVariables>(MyMessengerConnectionsDocument, {}, options);
}
export function useMyMessengerConnectionsLazyQuery(options: VueApolloComposable.UseQueryOptions<MyMessengerConnectionsQuery, MyMessengerConnectionsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<MyMessengerConnectionsQuery, MyMessengerConnectionsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<MyMessengerConnectionsQuery, MyMessengerConnectionsQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<MyMessengerConnectionsQuery, MyMessengerConnectionsQueryVariables>(MyMessengerConnectionsDocument, {}, options);
}
export type MyMessengerConnectionsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<MyMessengerConnectionsQuery, MyMessengerConnectionsQueryVariables>;
export const MyNotificationHistoryDocument = gql`
query MyNotificationHistory($channel: MessengerType!, $limit: Int) {
myNotificationHistory(channel: $channel, limit: $limit) {
id
channel
title
message
createdAt
orderId
}
}
`;
/**
* __useMyNotificationHistoryQuery__
*
* To run a query within a Vue component, call `useMyNotificationHistoryQuery` and pass it any options that fit your needs.
* When your component renders, `useMyNotificationHistoryQuery` returns an object from Apollo Client that contains result, loading and error properties
* you can use to render your UI.
*
* @param variables that will be passed into the query
* @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
*
* @example
* const { result, loading, error } = useMyNotificationHistoryQuery({
* channel: // value for 'channel'
* limit: // value for 'limit'
* });
*/
export function useMyNotificationHistoryQuery(variables: MyNotificationHistoryQueryVariables | VueCompositionApi.Ref<MyNotificationHistoryQueryVariables> | ReactiveFunction<MyNotificationHistoryQueryVariables>, options: VueApolloComposable.UseQueryOptions<MyNotificationHistoryQuery, MyNotificationHistoryQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<MyNotificationHistoryQuery, MyNotificationHistoryQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<MyNotificationHistoryQuery, MyNotificationHistoryQueryVariables>> = {}) {
return VueApolloComposable.useQuery<MyNotificationHistoryQuery, MyNotificationHistoryQueryVariables>(MyNotificationHistoryDocument, variables, options);
}
export function useMyNotificationHistoryLazyQuery(variables?: MyNotificationHistoryQueryVariables | VueCompositionApi.Ref<MyNotificationHistoryQueryVariables> | ReactiveFunction<MyNotificationHistoryQueryVariables>, options: VueApolloComposable.UseQueryOptions<MyNotificationHistoryQuery, MyNotificationHistoryQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<MyNotificationHistoryQuery, MyNotificationHistoryQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<MyNotificationHistoryQuery, MyNotificationHistoryQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<MyNotificationHistoryQuery, MyNotificationHistoryQueryVariables>(MyNotificationHistoryDocument, variables, options);
}
export type MyNotificationHistoryQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<MyNotificationHistoryQuery, MyNotificationHistoryQueryVariables>;
export const SendTestMessengerMessageDocument = gql`
mutation SendTestMessengerMessage($type: MessengerType!, $channelId: String, $message: String) {
sendTestMessengerMessage(type: $type, channelId: $channelId, message: $message) {
type
channelId
success
detail
sentAt
}
}
`;
/**
* __useSendTestMessengerMessageMutation__
*
* To run a mutation, you first call `useSendTestMessengerMessageMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useSendTestMessengerMessageMutation` 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 } = useSendTestMessengerMessageMutation({
* variables: {
* type: // value for 'type'
* channelId: // value for 'channelId'
* message: // value for 'message'
* },
* });
*/
export function useSendTestMessengerMessageMutation(options: VueApolloComposable.UseMutationOptions<SendTestMessengerMessageMutation, SendTestMessengerMessageMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<SendTestMessengerMessageMutation, SendTestMessengerMessageMutationVariables>> = {}) {
return VueApolloComposable.useMutation<SendTestMessengerMessageMutation, SendTestMessengerMessageMutationVariables>(SendTestMessengerMessageDocument, options);
}
export type SendTestMessengerMessageMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<SendTestMessengerMessageMutation, SendTestMessengerMessageMutationVariables>;
export const ClientReviewOrderDocument = gql`
mutation ClientReviewOrder($orderId: ID!, $decision: Decision!) {
clientReviewOrder(orderId: $orderId, decision: $decision) {
id
status
clientApproved
managerApproved
}
}
`;
/**
* __useClientReviewOrderMutation__
*
* To run a mutation, you first call `useClientReviewOrderMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useClientReviewOrderMutation` 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 } = useClientReviewOrderMutation({
* variables: {
* orderId: // value for 'orderId'
* decision: // value for 'decision'
* },
* });
*/
export function useClientReviewOrderMutation(options: VueApolloComposable.UseMutationOptions<ClientReviewOrderMutation, ClientReviewOrderMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<ClientReviewOrderMutation, ClientReviewOrderMutationVariables>> = {}) {
return VueApolloComposable.useMutation<ClientReviewOrderMutation, ClientReviewOrderMutationVariables>(ClientReviewOrderDocument, options);
}
export type ClientReviewOrderMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<ClientReviewOrderMutation, ClientReviewOrderMutationVariables>;
export const MyCurrentOrdersDocument = gql`
query MyCurrentOrders {
myCurrentOrders {

View File

@@ -0,0 +1,14 @@
export default defineNuxtRouteMiddleware((to) => {
const config = useRuntimeConfig();
const authCookieName = config.public.authCookieName || 'fregat_auth_token';
const authToken = useCookie<string | null>(authCookieName);
const isLoginPage = to.path === '/login';
if (!authToken.value && !isLoginPage) {
return navigateTo('/login');
}
if (authToken.value && isLoginPage) {
return navigateTo('/products');
}
});

178
app/pages/login.vue Normal file
View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import {
RequestLoginCodeDocument,
VerifyLoginCodeDocument,
} from '~/composables/graphql/generated';
type LoginChannel = 'EMAIL' | 'TELEGRAM' | 'MAX';
const config = useRuntimeConfig();
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 requestCodeMutation = useMutation(RequestLoginCodeDocument);
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument);
const channelHint = computed(() => {
if (channel.value === 'EMAIL') {
return 'Email адрес';
}
if (channel.value === 'TELEGRAM') {
return 'Telegram channel id';
}
return 'Max channel id';
});
async function requestCode() {
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;
}
authCookie.value = payload.accessToken;
await navigateTo('/products');
}
</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">
Получите одноразовый код и подтвердите вход.
</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="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 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-if="feedback"
class="alert mt-4"
:class="feedbackTone === 'success' ? 'alert-success' : 'alert-error'"
>
{{ feedback }}
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,8 @@
mutation RequestLoginCode($input: RequestLoginCodeInput!) {
requestLoginCode(input: $input) {
challengeToken
channel
destination
expiresAt
}
}

View File

@@ -0,0 +1,12 @@
mutation VerifyLoginCode($input: VerifyLoginCodeInput!) {
verifyLoginCode(input: $input) {
accessToken
expiresAt
user {
id
email
fullName
role
}
}
}

View File

@@ -11,6 +11,12 @@ enum MessengerType {
MAX
}
enum LoginChannel {
EMAIL
TELEGRAM
MAX
}
enum RegistrationStatus {
PENDING
APPROVED
@@ -59,6 +65,19 @@ type User {
company: Company
}
type AuthCodeRequestResult {
challengeToken: String!
channel: LoginChannel!
destination: String!
expiresAt: DateTime!
}
type AuthSession {
accessToken: String!
expiresAt: DateTime!
user: User!
}
type Invitation {
id: ID!
token: String!
@@ -92,6 +111,23 @@ type MessengerConnection {
isActive: Boolean!
}
type MessengerDispatchResult {
type: MessengerType!
channelId: String!
success: Boolean!
detail: String!
sentAt: DateTime!
}
type NotificationHistoryItem {
id: ID!
channel: MessengerType!
title: String!
message: String!
createdAt: DateTime!
orderId: ID
}
type Warehouse {
id: ID!
code: String!
@@ -186,6 +222,9 @@ type ReferralStats {
type Query {
healthcheck: String!
me: User
myMessengerConnections: [MessengerConnection!]!
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
clientProducts: [Product!]!
myOrders: [Order!]!
myCurrentOrders: [Order!]!
@@ -194,6 +233,16 @@ type Query {
referralStats: ReferralStats!
}
input RequestLoginCodeInput {
channel: LoginChannel!
destination: String!
}
input VerifyLoginCodeInput {
challengeToken: String!
code: String!
}
input RegisterSelfInput {
companyName: String!
inn: String
@@ -272,11 +321,14 @@ input ReviewRewardWithdrawalInput {
}
type Mutation {
requestLoginCode(input: RequestLoginCodeInput!): AuthCodeRequestResult!
verifyLoginCode(input: VerifyLoginCodeInput!): AuthSession!
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
createInvitation(input: CreateInvitationInput!): Invitation!
acceptInvitation(input: AcceptInvitationInput!): User!
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
sendTestMessengerMessage(type: MessengerType!, channelId: String, message: String): MessengerDispatchResult!
submitReadyOrder(input: SubmitReadyOrderInput!): Order!
submitCalculationOrder(input: SubmitCalculationOrderInput!): Order!

View File

@@ -15,6 +15,7 @@ export default defineNuxtConfig({
'http://localhost:4000/graphql',
authCookieName,
public: {
authCookieName,
sentryDsn: process.env.NUXT_PUBLIC_SENTRY_DSN ?? '',
sentryEnvironment: process.env.NUXT_PUBLIC_SENTRY_ENVIRONMENT ?? 'development',
sentryRelease: process.env.NUXT_PUBLIC_SENTRY_RELEASE ?? 'dev',

View File

@@ -3,6 +3,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event);
const cookie = getHeader(event, 'cookie');
const authorization = getHeader(event, 'authorization');
const userId = getHeader(event, 'x-user-id');
const response = await fetch(config.backendGraphqlUrl, {
@@ -10,6 +11,7 @@ export default defineEventHandler(async (event) => {
headers: {
'content-type': 'application/json',
...(cookie ? { cookie } : {}),
...(authorization ? { authorization } : {}),
...(userId ? { 'x-user-id': userId } : {}),
},
body: JSON.stringify(body),