Merge pull request #1 from dikstub-rssa/feat/dashboard

Feat/dashboard
This commit is contained in:
Abizarah | 比周
2025-08-10 17:10:40 +07:00
committed by GitHub
34 changed files with 7205 additions and 8064 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "es5",
"trailingComma": "all",
"printWidth": 120,
"semi": false,
"plugins": ["prettier-plugin-tailwindcss"]
+6 -6
View File
@@ -12,15 +12,15 @@ RSSA - Front End
## Directory Structure for `app/`
- `app.vue`: Main layout
- `components` : Contains all reusable UI components.
- `components/flows` : Entry point for business logic and workflows. Pages or routes call these flow components to handle API requests and process application logic
- `components/app` : View-layer components that manage and present data. These are used within `flows/` to render or handle specific parts of the UI, and return results back to the flow
- `components/flow` : Entry point for business logic and workflows. Pages or routes call these flow components to handle API requests and process application logic
- `components/app` : View-layer components that manage and present data. These are used within `flow/` to render or handle specific parts of the UI, and return results back to the flow
- `components/pub` : Public/shared components used across different parts of the app.
- `composables` : Contains reusable logic and utility functions (e.g. composables, hooks)..
- `layouts` : Reusable UI layout patterns used across pages.
## Directory Structure for `app/pages`
- `pages/auth` : Authentication related pages.
- `pages/(features)` : Grouped feature modules that reflect specific business flows or domains.
- `pages/(features)` : Grouped feature modules that reflect specific business flow or domains.
## Directory Structure for `server/`
- `server/api` : API or proxy requests
@@ -37,13 +37,13 @@ The basic development workflow follows these steps:
- Keep components pure, avoid making HTTP requests directly within them.
- They receive data via props and emit events upward.
### Business Logic in `components/flows`
### Business Logic in `components/flow`
- This layer connects the UI with the logic (API calls, validations, navigation).
- It composes components from `components/app/`, `components/pub/`, and other flows.
- It composes components from `components/app/`, `components/pub/`, and other flow.
- Also responsible for managing state, side effects, and interactions.
### Create Pages in `pages/`
- Pages load the appropriate flow from `components/flows/`.
- Pages load the appropriate flow from `components/flow/`.
- They do not contain UI or logic directly, just route level layout or guards.
## Git Workflows
-70
View File
@@ -1,70 +0,0 @@
<script setup lang="ts">
import { Loader2 } from 'lucide-vue-next'
const email = ref('demo@gmail.com')
const password = ref('password')
const isLoading = ref(false)
async function onSubmit(event: Event) {
event.preventDefault()
if (!email.value || !password.value) return
isLoading.value = true
try {
const { data: respData } = await useFetch('/api/v1/authentication/login', {
method: 'POST',
body: JSON.stringify({
name: 'system',
password: 'the-SYSTEM-1234',
}),
})
const resp = respData.value
if (!resp) throw new Error('No response')
const { data: rawdata, meta } = resp
console.log('DATA', rawdata)
console.log('META', meta)
if (meta.status === 'verified') {
await nextTick()
navigateTo('/')
}
} catch (error) {
console.error('Login failed:', error)
} finally {
isLoading.value = false
}
}
</script>
<template>
<form class="grid gap-6" @submit="onSubmit">
<div class="grid gap-2">
<Label for="email"> Email </Label>
<Input
id="email"
v-model="email"
type="email"
placeholder="name@example.com"
:disabled="isLoading"
auto-capitalize="none"
auto-complete="email"
auto-correct="off"
/>
</div>
<div class="grid gap-2">
<div class="flex items-center">
<Label for="password"> Password </Label>
</div>
<Input id="password" v-model="password" type="password" :disabled="isLoading" />
</div>
<Button type="submit" class="w-full" :disabled="isLoading">
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
Login
</Button>
</form>
</template>
<style scoped></style>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import type { z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import { Loader2 } from 'lucide-vue-next'
import { useForm } from 'vee-validate'
interface Props {
schema: z.ZodSchema<any>
isLoading: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
submit: [data: any]
}>()
const { handleSubmit, defineField, errors, meta } = useForm({
validationSchema: toTypedSchema(props.schema),
initialValues: {
name: '',
password: '',
},
})
const [name, nameAttrs] = defineField('name')
const [password, passwordAttrs] = defineField('password')
const onSubmit = handleSubmit(async (values) => {
try {
await emit('submit', values)
} catch (error) {
console.error('Submission failed:', error)
}
})
</script>
<template>
<form class="grid gap-6" @submit="onSubmit">
<div class="grid gap-2">
<Label for="name">Username</Label>
<Input
id="name"
v-model="name"
v-bind="nameAttrs"
:disabled="isLoading"
:class="{ 'border-red-500': errors.name }"
/>
<span v-if="errors.name" class="text-sm text-red-500">
{{ errors.name }}
</span>
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input
id="password"
v-model="password"
v-bind="passwordAttrs"
type="password"
:disabled="isLoading"
:class="{ 'border-red-500': errors.password }"
/>
<span v-if="errors.password" class="text-sm text-red-500">
{{ errors.password }}
</span>
</div>
<Button type="submit" class="w-full" :disabled="isLoading || !meta.valid">
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
Login
</Button>
</form>
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts"></script>
<template>
<div>entry form</div>
<div>
<PubNavFooterCs />
</div>
</template>
View File
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { z } from 'zod'
const loginSchema = z.object({
name: z.string().min(6, 'Please enter a valid username'),
password: z.string().min(6, 'Password must be at least 6 characters'),
})
const { login } = useUserStore()
type LoginFormData = z.infer<typeof loginSchema>
const isLoading = ref(false)
const apiErrors = ref<Record<string, string>>({})
async function onSubmit(data: LoginFormData) {
isLoading.value = true
const result = await xfetch('/api/v1/authentication/login', 'POST', {
name: data.name,
password: data.password,
})
if (result.success) {
const { data: rawdata, meta } = result.body
if (meta.status === 'verified') {
await login(rawdata)
await navigateTo('/')
}
} else {
if (result.errors) {
Object.entries(result.errors).forEach(
([field, errorInfo]: [string, any]) => (apiErrors.value[field] = errorInfo.message),
)
} else {
apiErrors.value.general = result.error?.message || result.message || 'Login failed'
}
}
isLoading.value = false
}
</script>
<template>
<AppAuthLogin :schema="loginSchema" :is-loading="isLoading" @submit="onSubmit" />
</template>
<style scoped></style>
+5
View File
@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<AppPatientEntryForm />
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
const refSearchNav = {
onClick: () => {
// open filter modal
},
onInput: (_val: string) => {
// filter patient list
},
onClear: () => {
// clear url param
},
}
const hreaderPrep: HeaderPrep = {
title: 'Pasien',
icon: 'bi bi-journal-check',
addNav: {
label: 'Tambah',
onClick: () => navigateTo('/patient/add'),
},
}
// NOTE: example api
async function getPatientList() {
const { data } = await xfetch('/api/v1/patient')
console.log('data patient', data)
}
onMounted(() => {
getPatientList()
})
</script>
<template>
<PubNavHeaderPrep :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" icon="i-lucide-add" />
</template>
+65 -17
View File
@@ -1,17 +1,59 @@
<script setup lang="ts">
const navMenu: any[] = [
{
heading: 'General',
heading: 'Menu Utama',
items: [
{
title: 'Home',
title: 'Dashboard',
icon: 'i-lucide-home',
link: '/',
},
{
title: 'Home',
icon: 'i-lucide-home',
link: '/',
title: 'Pasien',
icon: 'i-lucide-users',
link: '/patient',
},
{
title: 'Rehabilitasi Medik',
icon: 'i-lucide-heart',
link: '/rehabilitasi',
},
{
title: 'Rawat Jalan',
icon: 'i-lucide-stethoscope',
link: '/rawat-jalan',
},
{
title: 'Rawat Inap',
icon: 'i-lucide-building-2',
link: '/rawat-inap',
},
{
title: 'VClaim BPJS',
icon: 'i-lucide-refresh-cw',
link: '/vclaim',
badge: 'Live',
},
{
title: 'SATUSEHAT',
icon: 'i-lucide-database',
link: '/satusehat',
badge: 'FHIR',
},
{
title: 'Medical Records',
icon: 'i-lucide-file-text',
link: '/medical-records',
},
{
title: 'Laporan',
icon: 'i-lucide-clipboard-list',
link: '/laporan',
},
{
title: 'Monitoring',
icon: 'i-lucide-bar-chart-3',
link: '/monitoring',
},
],
},
@@ -43,15 +85,15 @@ const teams: {
},
]
const user: {
name: string
email: string
avatar: string
} = {
name: 'John Doe',
email: 'john.doe@email.com',
avatar: '/avatars/avatartion.png',
}
// const user: {
// name: string
// email: string
// avatar: string
// } = {
// name: '',
// email: '',
// avatar: '/',
// }
// const { sidebar } = useAppSettings()
const sidebar = {
@@ -65,14 +107,20 @@ const sidebar = {
<Sidebar :collapsible="sidebar.collapsible" :side="sidebar.side" :variant="sidebar.variant">
<SidebarHeader>
<LayoutSidebarNavHeader :teams="teams" />
<Search />
<!-- <Search /> -->
</SidebarHeader>
<SidebarContent>
<SidebarGroup v-for="(nav, indexGroup) in navMenu" :key="indexGroup">
<SidebarGroupLabel v-if="nav.heading">
{{ nav.heading }}
</SidebarGroupLabel>
<component :is="resolveNavItemComponent(item)" v-for="(item, index) in nav.items" :key="index" :item="item" />
<component
:is="resolveNavItemComponent(item)"
v-for="(item, index) in nav.items"
:key="index"
:item="item"
class="mb-2"
/>
</SidebarGroup>
<SidebarGroup class="mt-auto">
<component
@@ -85,7 +133,7 @@ const sidebar = {
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<LayoutSidebarNavFooter :user="user" />
<LayoutSidebarNavFooter />
</SidebarFooter>
<SidebarRail />
</Sidebar>
+1 -1
View File
@@ -6,7 +6,7 @@ defineProps<{
<template>
<div
class="relative flex h-dvh items-center justify-center bg-[url('/rssa-login-page.jpg')] px-4 lg:max-w-none lg:px-0"
class="relative flex h-dvh items-center justify-center px-4 lg:max-w-none lg:px-0"
:class="{ 'flex-row-reverse': reverse }"
>
<div class="bg-muted relative hidden h-full flex-1 flex-col p-10 text-white lg:flex dark:border-r">
+22 -29
View File
@@ -1,18 +1,21 @@
<script setup lang="ts">
import { useSidebar } from '~/components/pub/ui/sidebar'
defineProps<{
user: {
name: string
email: string
avatar: string
}
}>()
// defineProps<{
// user: {
// name: string
// email: string
// avatar: string
// }
// }>()
const { isMobile } = useSidebar()
const { logout } = useUserStore()
const userStore = useUserStore().user
function handleLogout() {
navigateTo('/auth/login')
logout()
}
const showModalTheme = ref(false)
@@ -28,19 +31,19 @@ const showModalTheme = ref(false)
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="user.avatar" :alt="user.name" />
<AvatarImage src="" :alt="userStore?.user_name" />
<AvatarFallback class="rounded-lg">
{{
user.name
.split(' ')
userStore?.user_name
?.split(' ')
.map((n) => n[0])
.join('')
}}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ user.name }}</span>
<span class="truncate text-xs">{{ user.email }}</span>
<span class="truncate font-semibold">{{ userStore?.user_name }}</span>
<span class="truncate text-xs">{{ userStore?.user_email }}</span>
</div>
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
</SidebarMenuButton>
@@ -53,19 +56,19 @@ const showModalTheme = ref(false)
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="user.avatar" :alt="user.name" />
<AvatarImage src="" :alt="userStore?.user_name" />
<AvatarFallback class="rounded-lg">
{{
user.name
.split(' ')
userStore?.user_name
?.split(' ')
.map((n) => n[0])
.join('')
}}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ user.name }}</span>
<span class="truncate text-xs">{{ user.email }}</span>
<span class="truncate font-semibold">{{ userStore?.user_name }}</span>
<span class="truncate text-xs">{{ userStore?.user_email }}</span>
</div>
</div>
</DropdownMenuLabel>
@@ -73,8 +76,8 @@ const showModalTheme = ref(false)
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click="showModalTheme = true">
<Icon name="i-lucide-paintbrush" />
Theme
<Icon name="i-lucide-user" />
Profile
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
@@ -86,16 +89,6 @@ const showModalTheme = ref(false)
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<Dialog v-model:open="showModalTheme">
<DialogContent>
<DialogHeader>
<DialogTitle>Customize</DialogTitle>
<DialogDescription class="text-muted-foreground text-xs"> Customize & Preview in Real Time </DialogDescription>
</DialogHeader>
<ThemeCustomize />
</DialogContent>
</Dialog>
</template>
<style scoped></style>
+6 -2
View File
@@ -9,7 +9,7 @@ withDefaults(
}>(),
{
size: 'default',
}
},
)
const { setOpenMobile } = useSidebar()
@@ -19,7 +19,11 @@ const { setOpenMobile } = useSidebar()
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton as-child :tooltip="item.title" :size="size">
<NuxtLink :to="item.link" @click="setOpenMobile(false)">
<NuxtLink
:to="item.link"
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200"
@click="setOpenMobile(false)"
>
<Icon :name="item.icon || ''" mode="svg" />
<span>{{ item.title }}</span>
<span
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts"></script>
<template>
<div class="flex justify-between">
<div>
<Button variant="outline" size="sm">
<Icon name="i-lucide-pencil" class="mr-1" />
Edit
</Button>
</div>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { HeaderPrep, RefSearchNav } from '../types.ts'
const props = defineProps<{
prep: HeaderPrep
refSearchNav: RefSearchNav
}>()
function emitSearchNavClick() {
props.refSearchNav.onClick()
}
function onInput(event: Event) {
props.refSearchNav.onInput((event.target as HTMLInputElement).value)
}
function btnClick() {
props.prep.addNav?.onClick()
}
</script>
<template>
<header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="ml-3 text-lg font-bold text-gray-900">
{{ prep.title }}
</div>
</div>
<div class="flex items-center">
<div class="ml-3 text-lg text-gray-900">
<Input
type="text"
placeholder="Search"
class="w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 sm:text-sm"
@click="emitSearchNavClick"
@input="onInput"
/>
</div>
<div v-if="prep.addNav" class="m-2 flex items-center">
<Button size="md" class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm" @click="btnClick">
<Icon name="i-lucide-plus" class="mr-2 h-4 w-4 align-middle" />
{{ prep.addNav.label }}
</Button>
</div>
</div>
</div>
</header>
</template>
+84
View File
@@ -0,0 +1,84 @@
import type { ComponentType } from '@unovis/ts'
export interface ListItemDto {
id: number
name: string
code: string
}
export interface RecComponent {
idx?: number
rec: object
props?: any
component: ComponentType
}
export interface Col {
span?: number
classVal?: string
style?: string
width?: number // specific for width
widthUnit?: string // specific for width
}
export interface Th {
label: string
colSpan?: number
rowSpan?: number
classVal?: string
childClassVal?: string
style?: string
childStyle?: string
hideOnSm?: boolean
}
export interface ButtonNav {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'negative' | 'ghost' | 'link'
classVal?: string
classValExt?: string
icon?: string
label: string
onClick?: () => void
}
export interface QuickSearchNav {
inputClass?: string
inputPlaceHolder?: string
btnClass?: string
btnIcon?: string
btnLabel?: string
mainField?: string
searchParams: object
onSubmit?: (searchParams: object) => void
}
export interface RefSearchNav {
onInput: (val: string) => void
onClick: () => void
onClear: () => void
}
// prepared header for relatively common usage
export interface HeaderPrep {
title?: string
icon?: string
refSearchNav?: RefSearchNav
quickSearchNav?: QuickSearchNav
filterNav?: ButtonNav
addNav?: ButtonNav
printNav?: ButtonNav
}
export interface KeyLabel {
key: string
label: string
}
export type FuncRecUnknown = (rec: unknown, idx: number) => unknown
export type FuncComponent = (rec: unknown, idx: number) => RecComponent
export type RecStrFuncUnknown = Record<string, FuncRecUnknown>
export type RecStrFuncComponent = Record<string, FuncComponent>
export interface KeyNames {
key: string
label: string
}
+70
View File
@@ -0,0 +1,70 @@
export interface XError {
code: string
message: string
expectedVal?: string
givenVal?: string
}
export type XErrors = Record<string, XError>
export interface XfetchResult {
success: boolean
status_code: number
body: object | any
errors?: XErrors | undefined
error?: XError | undefined
message?: string
}
export type XfetchMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
export async function xfetch(
url: string,
method: XfetchMethod = 'GET',
input?: object | FormData,
headers?: any,
_type = 'json',
): Promise<XfetchResult> {
let success = false
let body: object | any = {}
let errors: XErrors = {}
let error: XError | undefined = {
code: '',
message: '',
}
let message: string | undefined = ''
if (!headers) {
headers = {}
}
if (input && !(input instanceof FormData)) {
headers['Content-Type'] = 'application/json'
}
try {
const res = await $fetch.raw(url, {
method,
headers,
body: input instanceof FormData ? input : JSON.stringify(input),
})
body = res._data
success = true
return { success, status_code: res.status, body, errors, error, message }
} catch (fetchError: any) {
const status = fetchError.response?.status || 500
const resJson = fetchError.data
if (resJson?.errors) {
errors = resJson.errors
} else if (resJson?.code && resJson?.message) {
error = { code: resJson.code, message: resJson.message }
} else if (resJson?.message) {
message = resJson.message
} else {
message = fetchError.message || 'Something went wrong'
}
return { success, status_code: status, body, errors, error, message }
}
}
+18
View File
@@ -0,0 +1,18 @@
export default defineNuxtRouteMiddleware((to) => {
const { $pinia } = useNuxtApp()
if (import.meta.client) {
const userStore = useUserStore($pinia)
console.log('currRole', userStore.userRole)
console.log('isAuth', userStore.isAuthenticated)
if (!userStore.isAuthenticated) {
return navigateTo('/auth/login')
}
const allowedRoles = to.meta.roles as string[] | undefined
if (allowedRoles && !allowedRoles.includes(userStore.userRole)) {
return navigateTo('/unauthorized')
}
}
})
-7
View File
@@ -1,7 +0,0 @@
export default defineNuxtRouteMiddleware((to, from) => {
// TODO: change this to actual api
const user = true
if (!user) {
return navigateTo('/auth/login')
}
})
@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
roles: ['sys', 'doc'],
})
</script>
<template>
<div>detail pasien</div>
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
roles: ['sys', 'doc'],
})
</script>
<template>
<div>edit pasien</div>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
roles: ['sys', 'doc'],
})
</script>
<template>
<FlowPatientAdd />
</template>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
definePageMeta({
roles: ['sys', 'doc'],
})
</script>
<template>
<div>
<FlowPatientList />
</div>
</template>
+1 -1
View File
@@ -10,7 +10,7 @@ definePageMeta({
<div class="grid gap-2 text-center">
<h1 class="text-2xl font-semibold tracking-tight">Login RSSA</h1>
</div>
<AppAuthSignIn />
<FlowAuthLogin />
</div>
</LayoutAuth>
</template>
+5 -3
View File
@@ -2,9 +2,11 @@
import { Activity, CreditCard, DollarSign, Users } from 'lucide-vue-next'
definePageMeta({
middleware: 'auth',
roles: ['sys', 'doc'],
})
const { userRole } = useUserStore()
const dataCard = ref({
totalRevenue: 0,
totalRevenueDesc: 0,
@@ -61,7 +63,7 @@ onMounted(() => {
<template>
<div class="flex w-full flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-2xl font-bold tracking-tight">Dashboard</h2>
<h2 class="text-2xl font-bold tracking-tight">Dashboard {{ userRole }}</h2>
<div class="flex items-center space-x-2"></div>
</div>
<main class="flex flex-1 flex-col gap-4 md:gap-8">
@@ -147,7 +149,7 @@ onMounted(() => {
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent class="pl-2">
<DashboardOverview />
<!-- <DashboardOverview /> -->
</CardContent>
</Card>
<Card>
+5
View File
@@ -0,0 +1,5 @@
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.$pinia.use(piniaPluginPersistedstate)
})
+31
View File
@@ -0,0 +1,31 @@
export const useUserStore = defineStore(
'user',
() => {
const user = ref<any | null>(null)
const isAuthenticated = computed(() => !!user.value)
const userRole = computed(() => user.value?.user_position_code || '')
const login = async (userData: any) => {
user.value = userData
}
const logout = () => {
user.value = null
}
return {
user,
isAuthenticated,
userRole,
login,
logout,
}
},
{
persist: {
key: 'user',
pick: ['user'],
},
},
)
+16
View File
@@ -0,0 +1,16 @@
// app/types/index.d.ts
import type { Pinia } from 'pinia'
declare module '#app' {
interface NuxtApp {
$pinia: Pinia
}
}
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$pinia: Pinia
}
}
export {}
+1
View File
@@ -12,6 +12,7 @@ export default withNuxt(
quotes: 'single',
// Less strict formatting
jsx: false,
trailingComma: 'all',
},
},
{
+2
View File
@@ -21,6 +21,8 @@
"embla-carousel": "^8.5.2",
"embla-carousel-vue": "^8.5.2",
"h3": "^1.15.4",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1",
"reka-ui": "^2.4.1"
},
"devDependencies": {
+6556 -7927
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import { defineEventHandler, getCookie, getRequestHeaders, getRequestURL, readBody } from 'h3'
const API_ORIGIN = process.env.API_ORIGIN as string
export default defineEventHandler(async (event) => {
const { method } = event.node.req
const headers = getRequestHeaders(event)
const url = getRequestURL(event)
const pathname = url.pathname.replace(/^\/api/, '')
const targetUrl = API_ORIGIN + pathname + (url.search || '')
const verificationId = headers['verification-id'] as string | undefined
let bearer = ''
if (verificationId) {
bearer = getCookie(event, `Verification-${verificationId}`) || ''
if (!bearer) bearer = getCookie(event, 'authentication') || ''
} else {
bearer = getCookie(event, 'authentication') || ''
}
const forwardHeaders = new Headers()
if (headers['content-type']) forwardHeaders.set('Content-Type', headers['content-type'])
forwardHeaders.set('Authorization', `Bearer ${bearer}`)
let body: any = undefined
if (['POST', 'PATCH'].includes(method!)) {
if (headers['content-type']?.includes('multipart/form-data')) {
body = await readBody(event)
} else {
body = await readBody(event)
forwardHeaders.set('Content-Type', 'application/json')
body = JSON.stringify(body)
}
}
const res = await fetch(targetUrl, {
method,
headers: forwardHeaders,
body,
})
return res
})