feat: add environment configuration and implement cookie handling for user authentication
This commit is contained in:
parent
9386b8dd69
commit
f9d861f24d
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8080
|
||||
VITE_SALT_KEY=legalGO
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=YOUR_API_URL
|
||||
VITE_SALT_KEY=YOUR_SALT_KEY
|
||||
20
app/apis/news/login.ts
Normal file
20
app/apis/news/login.ts
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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<typeof loginSchema>
|
||||
export type TLoginSchema = z.infer<typeof loginSchema>
|
||||
|
||||
type TProperties = {
|
||||
setIsRegisterOpen: NewsContextProperties['setIsRegisterOpen']
|
||||
@ -28,26 +29,33 @@ export const FormLogin = (properties: TProperties) => {
|
||||
setIsForgetOpen,
|
||||
setIsInitSubscribeOpen,
|
||||
} = properties
|
||||
const fetcher = useFetcher()
|
||||
|
||||
const formMethods = useForm<TLoginSchema>({
|
||||
const formMethods = useRemixForm<TLoginSchema>({
|
||||
mode: 'onSubmit',
|
||||
fetcher,
|
||||
resolver: zodResolver(loginSchema),
|
||||
})
|
||||
|
||||
const { handleSubmit } = formMethods
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
console.log('data', data) // eslint-disable-line no-console
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.success) {
|
||||
setIsInitSubscribeOpen(true)
|
||||
setIsLoginOpen(false)
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetcher])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<FormProvider {...formMethods}>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
<RemixFormProvider {...formMethods}>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
action="/actions/news/login"
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
@ -86,8 +94,8 @@ export const FormLogin = (properties: TProperties) => {
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</fetcher.Form>
|
||||
</RemixFormProvider>
|
||||
|
||||
{/* Link Daftar */}
|
||||
<div className="mt-4 text-center text-sm">
|
||||
|
||||
@ -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<typeof loader>('routes/_layout.news')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[60px] items-center justify-between bg-white px-5 align-middle sm:h-[100px] sm:gap-[15px] sm:px-[50px] sm:py-[20px]">
|
||||
@ -31,15 +34,8 @@ export const HeaderTop = () => {
|
||||
className="hidden sm:block"
|
||||
onClick={() => setIsLoginOpen(true)}
|
||||
>
|
||||
Akun
|
||||
{loaderData?.userToken ? 'Logout' : 'Masuk'}
|
||||
</Button>
|
||||
<div className="w-[50px] sm:w-[60px]">
|
||||
<img
|
||||
alt="language"
|
||||
src="/flags/id.svg"
|
||||
className="shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -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',
|
||||
})
|
||||
|
||||
16
app/libs/cookies.ts
Normal file
16
app/libs/cookies.ts
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
|
||||
60
app/routes/actions.news.login.ts
Normal file
60
app/routes/actions.news.login.ts
Normal file
@ -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<TLoginSchema>(
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
21
app/utils/token.ts
Normal file
21
app/utils/token.ts
Normal file
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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"
|
||||
|
||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user