From efae331aaf7bf51533506387fba15698472ab8e3 Mon Sep 17 00:00:00 2001 From: Umar Adilov <99314948+adilovcode@users.noreply.github.com> Date: Thu, 1 May 2025 01:16:11 +0500 Subject: [PATCH] Added authentication support --- package.json | 1 + pnpm-lock.yaml | 21 +++++++ src/app/api/auth/login/route.ts | 28 +++++----- .../middlewares/error-handler.middleware.ts | 18 ++++++ src/app/api/pages/main/route.ts | 8 ++- src/entities/auth/api/login.api.ts | 25 ++++----- .../auth/model/contracts/login.contract.ts | 2 +- .../model/validation}/login-form.schema.ts | 0 .../auth/login-form/ui/login-form.tsx | 19 +++---- src/pages-templates/login/index.tsx | 56 ++++++++++++++++++- src/shared/api/base-api.ts | 21 ++++++- src/shared/store/root-reducer.ts | 3 - 12 files changed, 158 insertions(+), 44 deletions(-) create mode 100644 src/app/api/middlewares/error-handler.middleware.ts rename src/{features/auth/login-form/model => entities/auth/model/validation}/login-form.schema.ts (100%) diff --git a/package.json b/package.json index 04d3507..6bedcc6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "axios": "^1.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cookies-next": "^5.1.0", "date-fns": "^4.1.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43ec80f..531d1c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cookies-next: + specifier: ^5.1.0 + version: 5.1.0(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -1401,6 +1404,16 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + + cookies-next@5.1.0: + resolution: {integrity: sha512-9Ekne+q8hfziJtnT9c1yDUBqT0eDMGgPrfPl4bpR3xwQHLTd/8gbSf6+IEkP/pjGsDZt1TGbC6emYmFYRbIXwQ==} + peerDependencies: + next: '>=15.0.0' + react: '>= 16.8.0' + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3930,6 +3943,14 @@ snapshots: concat-map@0.0.1: {} + cookie@1.0.2: {} + + cookies-next@5.1.0(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): + dependencies: + cookie: 1.0.2 + next: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index df541d9..a51a83c 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -3,14 +3,16 @@ import { z } from 'zod'; import oriyoClient from '@/app/api-utlities/utilities/oriyo.client'; -export const POST = async (req: NextRequest) => { - const validatedBody = z - .object({ - phoneNumber: z.string().min(9).max(9), - cardNumber: z.string().nonempty(), - type: z.enum(['corporate', 'bonus']), - }) - .parse(req.body); +import { loginFormSchema } from '@/entities/auth/model/validation/login-form.schema'; + +import { validationErrorHandler } from '../../middlewares/error-handler.middleware'; + +const routeHandler = async (req: NextRequest) => { + const body = await req.json(); + + const validatedBody = loginFormSchema + .merge(z.object({ type: z.enum(['bonus', 'corporate']) })) + .parse(body); try { const oriyoResponse = await oriyoClient.get('/client/login', { @@ -21,19 +23,17 @@ export const POST = async (req: NextRequest) => { }, }); - const { token } = oriyoResponse.data; + const { token } = JSON.parse(oriyoResponse.data); if (!token) { - return NextResponse.json({ error: 'No auth token' }, { status: 401 }); + return NextResponse.json({ error: 'Credentials error' }, { status: 401 }); } const response = NextResponse.json({ success: true }); - response.cookies.set('token', token, { - httpOnly: true, + response.cookies.set(`${validatedBody.type}__token`, token, { path: '/', maxAge: 2 * 60 * 60, - secure: process.env.NODE_ENV === 'production', }); return response; @@ -42,3 +42,5 @@ export const POST = async (req: NextRequest) => { return NextResponse.json({ error: 'Server error' }, { status: 500 }); } }; + +export const POST = validationErrorHandler(routeHandler); diff --git a/src/app/api/middlewares/error-handler.middleware.ts b/src/app/api/middlewares/error-handler.middleware.ts new file mode 100644 index 0000000..a3046e8 --- /dev/null +++ b/src/app/api/middlewares/error-handler.middleware.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { ZodError } from 'zod'; + +export const validationErrorHandler = + (handler: Function) => async (req: NextRequest, res: NextResponse) => { + try { + return await handler(req, res); + } catch (error) { + if (error instanceof ZodError) + return NextResponse.json({ message: error.format() }, { status: 400 }); + + console.error(error); + return NextResponse.json( + { message: 'Server died for some reason' }, + { status: 500 }, + ); + } + }; diff --git a/src/app/api/pages/main/route.ts b/src/app/api/pages/main/route.ts index f17286f..deb8f1b 100644 --- a/src/app/api/pages/main/route.ts +++ b/src/app/api/pages/main/route.ts @@ -7,7 +7,9 @@ import { import { mainPageRequest } from '@/app/api-utlities/requests/main-page.request'; import { requestTaylor } from '@/app/api-utlities/utilities/taylor.client'; -export async function GET(request: Request) { +import { validationErrorHandler } from '../../middlewares/error-handler.middleware'; + +const routeHandler = async (request: Request) => { const response = await requestTaylor(mainPageRequest); return new Response( @@ -21,4 +23,6 @@ export async function GET(request: Request) { headers: { 'Content-Type': 'application/json' }, }, ); -} +}; + +export const GET = validationErrorHandler(routeHandler); diff --git a/src/entities/auth/api/login.api.ts b/src/entities/auth/api/login.api.ts index 99ad4ed..5760186 100644 --- a/src/entities/auth/api/login.api.ts +++ b/src/entities/auth/api/login.api.ts @@ -1,24 +1,23 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { baseAPI } from '@/shared/api/base-api'; import { LoginParams, LoginResponse } from '../model/contracts/login.contract'; -export const loginAPI = createApi({ - baseQuery: fetchBaseQuery({ baseUrl: '/api' }), - endpoints: (build) => ({ - login: build.query({ +export const authenticationApi = baseAPI.injectEndpoints({ + endpoints: (builder) => ({ + login: builder.query({ query: (data) => { - const params = new URLSearchParams({ - type: data.type, - phoneNumber: data.phoneNumber, - cardNumber: data.cardNumber, - }).toString(); - return { - url: `/auth/login?${params}`, + method: 'POST', + body: { + type: data.type, + phoneNumber: data.phoneNumber, + cardNumber: data.cardNumber, + }, + url: '/auth/login', }; }, }), }), }); -export const { useLazyLoginQuery } = loginAPI; +export const { useLazyLoginQuery } = authenticationApi; diff --git a/src/entities/auth/model/contracts/login.contract.ts b/src/entities/auth/model/contracts/login.contract.ts index cb3a3da..b315099 100644 --- a/src/entities/auth/model/contracts/login.contract.ts +++ b/src/entities/auth/model/contracts/login.contract.ts @@ -1,4 +1,4 @@ -import { LoginFormData } from '@/features/auth/login-form/model/login-form.schema'; +import { LoginFormData } from '@/entities/auth/model/validation/login-form.schema'; export interface LoginResponse { success: boolean; diff --git a/src/features/auth/login-form/model/login-form.schema.ts b/src/entities/auth/model/validation/login-form.schema.ts similarity index 100% rename from src/features/auth/login-form/model/login-form.schema.ts rename to src/entities/auth/model/validation/login-form.schema.ts diff --git a/src/features/auth/login-form/ui/login-form.tsx b/src/features/auth/login-form/ui/login-form.tsx index 30d7044..1af10a3 100644 --- a/src/features/auth/login-form/ui/login-form.tsx +++ b/src/features/auth/login-form/ui/login-form.tsx @@ -19,7 +19,10 @@ import { } from '@/shared/shadcn-ui/form'; import { Input } from '@/shared/shadcn-ui/input'; -import { LoginFormData, loginFormSchema } from '../model/login-form.schema'; +import { + LoginFormData, + loginFormSchema, +} from '../../../../entities/auth/model/validation/login-form.schema'; interface LoginFormProps { type: 'bonus' | 'corporate'; @@ -40,16 +43,12 @@ export const LoginForm = ({ type }: LoginFormProps) => { }); const onSubmit = async (data: LoginFormData) => { - try { - await login({ ...data, type }).unwrap(); + await login({ ...data, type }).unwrap(); - toast.success('Logged in successfully!'); - router.push( - type === 'bonus' ? '/customer-dashboard' : '/corporate-dashboard', - ); - } catch (error) { - toast.error('An error occured during login'); - } + toast.success('Logged in successfully!'); + router.push( + type === 'bonus' ? '/customer-dashboard' : '/corporate-dashboard', + ); }; return ( diff --git a/src/pages-templates/login/index.tsx b/src/pages-templates/login/index.tsx index 23046b4..c248838 100644 --- a/src/pages-templates/login/index.tsx +++ b/src/pages-templates/login/index.tsx @@ -1,11 +1,14 @@ 'use client'; +import { deleteCookie, getCookie } from 'cookies-next'; import { Building2, Fuel, User } from 'lucide-react'; import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; import { LoginForm } from '@/features/auth/login-form'; import { useTextController } from '@/shared/language/hooks/use-text-controller'; +import { Button } from '@/shared/shadcn-ui/button'; import { Card, CardContent, @@ -39,6 +42,13 @@ const tabs = [ export default function LoginPage() { const { t } = useTextController(); + const router = useRouter(); + const searchParams = useSearchParams(); + const defaultTab = searchParams.get('tab') || 'bonus'; + + const handleTabChange = (tabType: string) => { + router.push(`?tab=${tabType}`, undefined, { shallow: true }); + }; return (
@@ -55,7 +65,12 @@ export default function LoginPage() {
- + {tabs.map((tab) => { return ( @@ -71,6 +86,45 @@ export default function LoginPage() { {tabs.map((tab) => { + const tabCookieName = `${tab.type}__token`; + + const authenticationCookie = getCookie(tabCookieName); + + if (authenticationCookie) { + return ( + + + + {t(tab.title)} + + + + + + + + + + ); + } + return ( diff --git a/src/shared/api/base-api.ts b/src/shared/api/base-api.ts index 5ecaa52..71616da 100644 --- a/src/shared/api/base-api.ts +++ b/src/shared/api/base-api.ts @@ -1,4 +1,5 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { toast } from 'sonner'; const baseQuery = fetchBaseQuery({ baseUrl: process.env.NEXT_PUBLIC_API_URL, @@ -15,7 +16,25 @@ export const TAGS = { export const baseAPI = createApi({ reducerPath: 'baseAPI', - baseQuery, + baseQuery: async (args, api, extraOptions) => { + const result = await baseQuery(args, api, extraOptions); + + if (result.error) { + switch (result.error.status) { + case 401: + toast.error('Login credentials error'); + break; + + case 500: + toast.error('Server error, please try later'); + break; + + default: + break; + } + } + return result; + }, tagTypes: Object.values(TAGS), endpoints: () => ({}), }); diff --git a/src/shared/store/root-reducer.ts b/src/shared/store/root-reducer.ts index 56b999c..1b1a8cd 100644 --- a/src/shared/store/root-reducer.ts +++ b/src/shared/store/root-reducer.ts @@ -1,10 +1,7 @@ import { combineReducers } from '@reduxjs/toolkit'; -import { loginAPI } from '@/entities/auth/api/login.api'; - import { baseAPI } from '@/shared/api/base-api'; export const rootReducer = combineReducers({ [baseAPI.reducerPath]: baseAPI.reducer, - [loginAPI.reducerPath]: loginAPI.reducer, });