From 1c4fd847dc4bee780ddb3aef022e768f4a556aea Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:10:18 +0700 Subject: [PATCH] Add OTP login page and auth guard for client cabinet --- app/app.vue | 7 +- app/composables/graphql/generated.ts | 332 ++++++++++++++++++ app/middleware/auth.global.ts | 14 + app/pages/login.vue | 178 ++++++++++ .../auth/request-login-code.graphql | 8 + .../operations/auth/verify-login-code.graphql | 12 + graphql/schema.graphql | 52 +++ nuxt.config.ts | 1 + server/api/graphql.post.ts | 2 + 9 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 app/middleware/auth.global.ts create mode 100644 app/pages/login.vue create mode 100644 graphql/operations/auth/request-login-code.graphql create mode 100644 graphql/operations/auth/verify-login-code.graphql diff --git a/app/app.vue b/app/app.vue index edb3700..d78783e 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,6 +1,11 @@ + + - + diff --git a/app/composables/graphql/generated.ts b/app/composables/graphql/generated.ts index 9a7b306..3b428f0 100644 --- a/app/composables/graphql/generated.ts +++ b/app/composables/graphql/generated.ts @@ -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; + message?: InputMaybe; + 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; + title: Scalars['String']['output']; +}; + export type Order = { __typename?: 'Order'; blockReason?: Maybe; @@ -285,20 +345,36 @@ export type Query = { __typename?: 'Query'; clientProducts: Array; healthcheck: Scalars['String']['output']; + managerNotificationHistory: Array; managerOrders: Array; me?: Maybe; myCurrentOrders: Array; + myMessengerConnections: Array; + myNotificationHistory: Array; myOrders: Array; referralStats: ReferralStats; registrationRequests: Array; }; +export type QueryManagerNotificationHistoryArgs = { + channel: MessengerType; + limit?: InputMaybe; + userId: Scalars['ID']['input']; +}; + + export type QueryManagerOrdersArgs = { status?: InputMaybe; }; +export type QueryMyNotificationHistoryArgs = { + channel: MessengerType; + limit?: InputMaybe; +}; + + export type QueryRegistrationRequestsArgs = { status?: InputMaybe; }; @@ -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; +}>; + + +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; + message?: InputMaybe; +}>; + + +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(RegisterSelfDocument, options); } export type RegisterSelfMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn; +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 | ReactiveFunction> = {}) { + return VueApolloComposable.useMutation(RequestLoginCodeDocument, options); +} +export type RequestLoginCodeMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn; +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 | ReactiveFunction> = {}) { + return VueApolloComposable.useMutation(VerifyLoginCodeDocument, options); +} +export type VerifyLoginCodeMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn; export const ClientProductsDocument = gql` query ClientProducts { clientProducts { @@ -541,6 +739,140 @@ export function useClientProductsLazyQuery(options: VueApolloComposable.UseQuery return VueApolloComposable.useLazyQuery(ClientProductsDocument, {}, options); } export type ClientProductsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn; +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 | VueCompositionApi.Ref> | ReactiveFunction> = {}) { + return VueApolloComposable.useQuery(MyMessengerConnectionsDocument, {}, options); +} +export function useMyMessengerConnectionsLazyQuery(options: VueApolloComposable.UseQueryOptions | VueCompositionApi.Ref> | ReactiveFunction> = {}) { + return VueApolloComposable.useLazyQuery(MyMessengerConnectionsDocument, {}, options); +} +export type MyMessengerConnectionsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn; +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 | ReactiveFunction, options: VueApolloComposable.UseQueryOptions | VueCompositionApi.Ref> | ReactiveFunction> = {}) { + return VueApolloComposable.useQuery(MyNotificationHistoryDocument, variables, options); +} +export function useMyNotificationHistoryLazyQuery(variables?: MyNotificationHistoryQueryVariables | VueCompositionApi.Ref | ReactiveFunction, options: VueApolloComposable.UseQueryOptions | VueCompositionApi.Ref> | ReactiveFunction> = {}) { + return VueApolloComposable.useLazyQuery(MyNotificationHistoryDocument, variables, options); +} +export type MyNotificationHistoryQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn; +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 | ReactiveFunction> = {}) { + return VueApolloComposable.useMutation(SendTestMessengerMessageDocument, options); +} +export type SendTestMessengerMessageMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn; +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 | ReactiveFunction> = {}) { + return VueApolloComposable.useMutation(ClientReviewOrderDocument, options); +} +export type ClientReviewOrderMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn; export const MyCurrentOrdersDocument = gql` query MyCurrentOrders { myCurrentOrders { diff --git a/app/middleware/auth.global.ts b/app/middleware/auth.global.ts new file mode 100644 index 0000000..7ee9f25 --- /dev/null +++ b/app/middleware/auth.global.ts @@ -0,0 +1,14 @@ +export default defineNuxtRouteMiddleware((to) => { + const config = useRuntimeConfig(); + const authCookieName = config.public.authCookieName || 'fregat_auth_token'; + const authToken = useCookie(authCookieName); + + const isLoginPage = to.path === '/login'; + if (!authToken.value && !isLoginPage) { + return navigateTo('/login'); + } + + if (authToken.value && isLoginPage) { + return navigateTo('/products'); + } +}); diff --git a/app/pages/login.vue b/app/pages/login.vue new file mode 100644 index 0000000..5d8eaa9 --- /dev/null +++ b/app/pages/login.vue @@ -0,0 +1,178 @@ + + + + + + + Вход в личный кабинет + + Получите одноразовый код и подтвердите вход. + + + + + + Email + + + Telegram + + + Max + + + + + + {{ channelHint }} + + + + + {{ requestCodeMutation.loading.value ? 'Отправляем…' : 'Получить код' }} + + + + + + Код отправлен на {{ maskedDestination }}. + Действителен до: {{ expiresAt }}. + + + + Код подтверждения + + + + + {{ verifyCodeMutation.loading.value ? 'Проверяем…' : 'Войти' }} + + + + Выбрать другой канал + + + + + {{ feedback }} + + + + diff --git a/graphql/operations/auth/request-login-code.graphql b/graphql/operations/auth/request-login-code.graphql new file mode 100644 index 0000000..cb9ab0a --- /dev/null +++ b/graphql/operations/auth/request-login-code.graphql @@ -0,0 +1,8 @@ +mutation RequestLoginCode($input: RequestLoginCodeInput!) { + requestLoginCode(input: $input) { + challengeToken + channel + destination + expiresAt + } +} diff --git a/graphql/operations/auth/verify-login-code.graphql b/graphql/operations/auth/verify-login-code.graphql new file mode 100644 index 0000000..ec1baf6 --- /dev/null +++ b/graphql/operations/auth/verify-login-code.graphql @@ -0,0 +1,12 @@ +mutation VerifyLoginCode($input: VerifyLoginCodeInput!) { + verifyLoginCode(input: $input) { + accessToken + expiresAt + user { + id + email + fullName + role + } + } +} diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 372b8db..53ae107 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -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! diff --git a/nuxt.config.ts b/nuxt.config.ts index 3ae773d..bb5801a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -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', diff --git a/server/api/graphql.post.ts b/server/api/graphql.post.ts index f6966d5..c3404ba 100644 --- a/server/api/graphql.post.ts +++ b/server/api/graphql.post.ts @@ -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),
+ Получите одноразовый код и подтвердите вход. +