From f9d861f24d0f298a4177ba897be0658d881c1949 Mon Sep 17 00:00:00 2001 From: Ardeman Date: Thu, 27 Feb 2025 19:37:31 +0800 Subject: [PATCH] feat: add environment configuration and implement cookie handling for user authentication --- .env | 2 + .env.example | 2 + app/apis/news/login.ts | 20 +++++++++ app/configs/{storages.ts => cookies.ts} | 0 app/layouts/news/form-login.tsx | 38 +++++++++------- app/layouts/news/header-top.tsx | 14 +++--- app/libs/cookie.server.ts | 12 +++-- app/libs/cookies.ts | 16 +++++++ app/libs/http-client.ts | 5 ++- app/libs/logout-header.server.ts | 14 +++++- app/routes/_layout.news.tsx | 11 +++++ app/routes/actions.news.login.ts | 60 +++++++++++++++++++++++++ app/utils/token.ts | 21 +++++++++ package.json | 2 + pnpm-lock.yaml | 26 +++++++++++ 15 files changed, 213 insertions(+), 30 deletions(-) create mode 100644 .env create mode 100644 .env.example create mode 100644 app/apis/news/login.ts rename app/configs/{storages.ts => cookies.ts} (100%) create mode 100644 app/libs/cookies.ts create mode 100644 app/routes/actions.news.login.ts create mode 100644 app/utils/token.ts diff --git a/.env b/.env new file mode 100644 index 0000000..c022451 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:8080 +VITE_SALT_KEY=legalGO diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9eac0b1 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_API_URL=YOUR_API_URL +VITE_SALT_KEY=YOUR_SALT_KEY diff --git a/app/apis/news/login.ts b/app/apis/news/login.ts new file mode 100644 index 0000000..506ea27 --- /dev/null +++ b/app/apis/news/login.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +import { type TLoginSchema } from '~/layouts/news/form-login' +import HttpClient from '~/libs/http-client' + +const loginResponseSchema = z.object({ + data: z.object({ + token: z.string(), + }), +}) + +export const newsLoginRequest = async (payload: TLoginSchema) => { + try { + const { data } = await HttpClient().post('/api/user/login', payload) + return loginResponseSchema.parse(data) + } catch (error) { + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject + return Promise.reject(error) + } +} diff --git a/app/configs/storages.ts b/app/configs/cookies.ts similarity index 100% rename from app/configs/storages.ts rename to app/configs/cookies.ts diff --git a/app/layouts/news/form-login.tsx b/app/layouts/news/form-login.tsx index 03408e8..f00439f 100644 --- a/app/layouts/news/form-login.tsx +++ b/app/layouts/news/form-login.tsx @@ -1,18 +1,19 @@ -// import { EyeIcon, EyeOffIcon } from 'lucide-react' import { zodResolver } from '@hookform/resolvers/zod' -import { FormProvider, useForm } from 'react-hook-form' +import { useEffect } from 'react' +import { useFetcher } from 'react-router' +import { RemixFormProvider, useRemixForm } from 'remix-hook-form' import { z } from 'zod' import { Button } from '~/components/ui/button' import { Input } from '~/components/ui/input' import type { NewsContextProperties } from '~/contexts/news' -const loginSchema = z.object({ +export const loginSchema = z.object({ email: z.string().email('Email tidak valid'), password: z.string().min(6, 'Kata sandi minimal 6 karakter'), }) -type TLoginSchema = z.infer +export type TLoginSchema = z.infer type TProperties = { setIsRegisterOpen: NewsContextProperties['setIsRegisterOpen'] @@ -28,26 +29,33 @@ export const FormLogin = (properties: TProperties) => { setIsForgetOpen, setIsInitSubscribeOpen, } = properties + const fetcher = useFetcher() - const formMethods = useForm({ + const formMethods = useRemixForm({ + mode: 'onSubmit', + fetcher, resolver: zodResolver(loginSchema), }) const { handleSubmit } = formMethods - const onSubmit = handleSubmit((data) => { - console.log('data', data) // eslint-disable-line no-console - setIsInitSubscribeOpen(true) - setIsLoginOpen(false) - }) + useEffect(() => { + if (fetcher.data?.success) { + setIsInitSubscribeOpen(true) + setIsLoginOpen(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher]) return (
- -
+ { > Masuk - -
+ + {/* Link Daftar */}
diff --git a/app/layouts/news/header-top.tsx b/app/layouts/news/header-top.tsx index 5b5c197..d00e200 100644 --- a/app/layouts/news/header-top.tsx +++ b/app/layouts/news/header-top.tsx @@ -1,11 +1,14 @@ -import { Link } from 'react-router' +import { Link, useRouteLoaderData } from 'react-router' import { Button } from '~/components/ui/button' import { APP } from '~/configs/meta' import { useNewsContext } from '~/contexts/news' +import type { loader } from '~/routes/_layout.news' export const HeaderTop = () => { const { setIsLoginOpen } = useNewsContext() + const loaderData = useRouteLoaderData('routes/_layout.news') + return ( <>
@@ -31,15 +34,8 @@ export const HeaderTop = () => { className="hidden sm:block" onClick={() => setIsLoginOpen(true)} > - Akun + {loaderData?.userToken ? 'Logout' : 'Masuk'} -
- language -
diff --git a/app/libs/cookie.server.ts b/app/libs/cookie.server.ts index 8b6826c..7d73eb4 100644 --- a/app/libs/cookie.server.ts +++ b/app/libs/cookie.server.ts @@ -1,13 +1,19 @@ import { createCookie } from 'react-router' -import { ADMIN_COOKIES, USER_COOKIES } from '~/configs/storages' +import { ADMIN_COOKIES, USER_COOKIES } from '~/configs/cookies' -export const userTokenCookie = createCookie(USER_COOKIES.token, { +export const userTokenCookieConfig = createCookie(USER_COOKIES.token, { + httpOnly: false, + sameSite: 'lax', secure: process.env.NODE_ENV === 'production', + secrets: [process.env.VITE_SALT_KEY || 'default-secret'], path: '/news', }) -export const adminTokenCookie = createCookie(ADMIN_COOKIES.token, { +export const adminTokenCookieConfig = createCookie(ADMIN_COOKIES.token, { + httpOnly: false, + sameSite: 'lax', secure: process.env.NODE_ENV === 'production', + secrets: [process.env.VITE_SALT_KEY || 'default-secret'], path: '/admin', }) diff --git a/app/libs/cookies.ts b/app/libs/cookies.ts new file mode 100644 index 0000000..2a84148 --- /dev/null +++ b/app/libs/cookies.ts @@ -0,0 +1,16 @@ +import { adminTokenCookieConfig, userTokenCookieConfig } from './cookie.server' + +export const handleCookie = async (request: Request) => { + const headers = request.headers + const userToken = (await userTokenCookieConfig.parse( + headers.get('Cookie'), + )) as string + const adminToken = (await adminTokenCookieConfig.parse( + headers.get('Cookie'), + )) as string + + return { + userToken, + adminToken, + } +} diff --git a/app/libs/http-client.ts b/app/libs/http-client.ts index 0257fb4..e02c267 100644 --- a/app/libs/http-client.ts +++ b/app/libs/http-client.ts @@ -1,7 +1,10 @@ import xior, { merge } from 'xior' const baseURL = import.meta.env.VITE_API_URL -const HttpClient = (token?: string) => { + +type THttpClient = { token?: string } +const HttpClient = (parameters?: THttpClient) => { + const { token } = parameters || {} const instance = xior.create({ baseURL, }) diff --git a/app/libs/logout-header.server.ts b/app/libs/logout-header.server.ts index 73dff66..548e920 100644 --- a/app/libs/logout-header.server.ts +++ b/app/libs/logout-header.server.ts @@ -1,10 +1,20 @@ -import { USER_COOKIES } from '~/configs/storages' +import { ADMIN_COOKIES, USER_COOKIES } from '~/configs/cookies' export const setUserLogoutHeaders = () => { const responseHeaders = new Headers() responseHeaders.append( 'Set-Cookie', - `${USER_COOKIES.token}=; Path=/news; Max-Age=0`, + `${USER_COOKIES.token}=; Path=/news; HttpOnly; SameSite=Strict; Max-Age=0`, + ) + + return responseHeaders +} + +export const setAdminLogoutHeaders = () => { + const responseHeaders = new Headers() + responseHeaders.append( + 'Set-Cookie', + `${ADMIN_COOKIES.token}=; Path=/admin; HttpOnly; SameSite=Strict; Max-Age=0`, ) return responseHeaders diff --git a/app/routes/_layout.news.tsx b/app/routes/_layout.news.tsx index 751a087..f25a003 100644 --- a/app/routes/_layout.news.tsx +++ b/app/routes/_layout.news.tsx @@ -2,6 +2,17 @@ import { Outlet } from 'react-router' import { NewsProvider } from '~/contexts/news' import { NewsDefaultLayout } from '~/layouts/news/default' +import { handleCookie } from '~/libs/cookies' + +import type { Route } from './+types/_layout.news' + +export const loader = async ({ request }: Route.LoaderArgs) => { + const { userToken } = await handleCookie(request) + + return { + userToken, + } +} const NewsLayout = () => { return ( diff --git a/app/routes/actions.news.login.ts b/app/routes/actions.news.login.ts new file mode 100644 index 0000000..e99c19d --- /dev/null +++ b/app/routes/actions.news.login.ts @@ -0,0 +1,60 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { data } from 'react-router' +import { getValidatedFormData } from 'remix-hook-form' +import { XiorError } from 'xior' + +import { newsLoginRequest } from '~/apis/news/login' +import { loginSchema, type TLoginSchema } from '~/layouts/news/form-login' +import { generateTokenCookie } from '~/utils/token' + +import type { Route } from './+types/actions.news.login' + +export const action = async ({ request }: Route.ActionArgs) => { + try { + const { + errors, + data: payload, + receivedValues: defaultValues, + } = await getValidatedFormData( + request, + zodResolver(loginSchema), + false, + ) + + if (errors) { + return data({ success: false, errors, defaultValues }, { status: 400 }) + } + + const { data: loginData } = await newsLoginRequest(payload) + const { token } = loginData + const tokenCookie = generateTokenCookie({ + token, + }) + + const headers = new Headers() + headers.append('Set-Cookie', await tokenCookie) + + return data( + { + success: true, + accessToken: token, + }, + { + headers, + status: 200, + statusText: 'OK', + }, + ) + } catch (error) { + if (error instanceof XiorError) { + return data({ + success: false, + message: error?.response?.data?.error?.message, + }) + } + return data({ + success: false, + message: 'Internal server error', + }) + } +} diff --git a/app/utils/token.ts b/app/utils/token.ts new file mode 100644 index 0000000..9bd4ebd --- /dev/null +++ b/app/utils/token.ts @@ -0,0 +1,21 @@ +import { decodeJwt } from 'jose' + +import { userTokenCookieConfig } from '~/libs/cookie.server' + +type TTokenCookie = { + token: string +} + +export const generateTokenCookie = (parameters: TTokenCookie) => { + const { token } = parameters + + const decodedToken = decodeJwt(token) + const decodedTokenExp = decodedToken.exp + const expirationDate = decodedTokenExp + ? new Date(decodedTokenExp * 1000) + : undefined + + return userTokenCookieConfig.serialize(token, { + expires: expirationDate, + }) +} diff --git a/package.json b/package.json index 2cb7b9a..510b5ac 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,13 @@ "embla-carousel-react": "^8.5.2", "html-react-parser": "^5.2.2", "isbot": "^5.1.17", + "jose": "^6.0.8", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-router": "^7.1.3", + "remix-hook-form": "^6.1.3", "tailwind-merge": "^3.0.1", "xior": "^0.6.3", "zod": "^3.24.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0626dcc..236f3a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: isbot: specifier: ^5.1.17 version: 5.1.22 + jose: + specifier: ^6.0.8 + version: 6.0.8 react: specifier: ^19.0.0 version: 19.0.0 @@ -59,6 +62,9 @@ importers: react-router: specifier: ^7.1.3 version: 7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + remix-hook-form: + specifier: ^6.1.3 + version: 6.1.3(react-dom@19.0.0(react@19.0.0))(react-hook-form@7.54.2(react@19.0.0))(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) tailwind-merge: specifier: ^3.0.1 version: 3.0.1 @@ -2788,6 +2794,9 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jose@6.0.8: + resolution: {integrity: sha512-EyUPtOKyTYq+iMOszO42eobQllaIjJnwkZ2U93aJzNyPibCy7CEvT9UQnaCVB51IAd49gbNdCew1c0LcLTCB2g==} + jquery@3.7.1: resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==} @@ -3629,6 +3638,14 @@ packages: resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==} hasBin: true + remix-hook-form@6.1.3: + resolution: {integrity: sha512-lpEWqdehtF0ok0D8varghH64mm/GFgbilPCMtQz/J1RVu+J/BPgYZgb44yhIYGI09HfNDADSXBTIvX4WLwJmwQ==} + peerDependencies: + react: ^18.2.0 || ^19.0.0 + react-dom: ^18.2.0 || ^19.0.0 + react-hook-form: ^7.51.0 + react-router: '>=7.0.0' + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -7164,6 +7181,8 @@ snapshots: jiti@2.4.2: {} + jose@6.0.8: {} + jquery@3.7.1: {} js-beautify@1.15.1: @@ -7936,6 +7955,13 @@ snapshots: dependencies: jsesc: 0.5.0 + remix-hook-form@6.1.3(react-dom@19.0.0(react@19.0.0))(react-hook-form@7.54.2(react@19.0.0))(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-hook-form: 7.54.2(react@19.0.0) + react-router: 7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + require-directory@2.1.1: {} require-from-string@2.0.2: {}