major: Migration from better-auth to keycloak
This commit is contained in:
@@ -1,39 +1,3 @@
|
||||
# Legal Consent Hub
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create `.env` file with these variables:
|
||||
```
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
BETTER_AUTH_SECRET=YOUR_SECRET
|
||||
```
|
||||
2. Generate database schema: `pnpm dlx @better-auth/cli generate`
|
||||
3. Migrate schema: `pnpm dlx @better-auth/cli migrate`
|
||||
|
||||
## Common errors
|
||||
|
||||
### better-auth/cli generate
|
||||
|
||||
```
|
||||
Couldn't read your auth config. Error: Could not locate the bindings file. Tried:
|
||||
```
|
||||
|
||||
**Solution:** I was able to resolve by running npx node-gyp rebuild in 'node_modules/better-sqlite3'
|
||||
|
||||
https://github.com/WiseLibs/better-sqlite3/issues/1320
|
||||
|
||||
https://github.com/WiseLibs/better-sqlite3/issues/146
|
||||
|
||||
### This version of Node.js requires NODE_MODULE_VERSION 131.
|
||||
|
||||
```
|
||||
rm -fr node_modules; pnpm store prune
|
||||
```
|
||||
|
||||
https://github.com/elizaOS/eliza/pull/665
|
||||
|
||||
### Unauthorized /token and /organization/list endpoints
|
||||
|
||||
User needs to be logged in to access these endpoints.
|
||||
|
||||
https://www.better-auth.com/docs/plugins/organization#accept-invitation
|
||||
|
||||
@@ -18,8 +18,8 @@ const color = computed(() => (colorMode.value === 'dark' ? '#111827' : 'white'))
|
||||
useHead({
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ key: 'theme-color', name: 'theme-color', content: color }
|
||||
{ userName: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ key: 'theme-color', userName: 'theme-color', content: color }
|
||||
],
|
||||
link: [{ rel: 'icon', href: '/favicon.ico' }],
|
||||
htmlAttrs: {
|
||||
@@ -39,4 +39,8 @@ useSeoMeta({
|
||||
twitterImage: 'https://dashboard-template.nuxt.dev/social-card.png',
|
||||
twitterCard: 'summary_large_image'
|
||||
})
|
||||
|
||||
// onBeforeMount(() => {
|
||||
// $fetch('/api/auth/refresh')
|
||||
// })
|
||||
</script>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null);
|
||||
|
||||
create table "session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id"), "activeOrganizationId" text);
|
||||
|
||||
create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id"), "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null);
|
||||
|
||||
create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" date not null, "createdAt" date, "updatedAt" date);
|
||||
|
||||
create table "jwks" ("id" text not null primary key, "publicKey" text not null, "privateKey" text not null, "createdAt" date not null);
|
||||
|
||||
create table "organization" ("id" text not null primary key, "name" text not null, "slug" text not null unique, "logo" text, "createdAt" date not null, "metadata" text);
|
||||
|
||||
create table "member" ("id" text not null primary key, "organizationId" text not null references "organization" ("id"), "userId" text not null references "user" ("id"), "role" text not null, "createdAt" date not null);
|
||||
|
||||
create table "invitation" ("id" text not null primary key, "organizationId" text not null references "organization" ("id"), "email" text not null, "role" text, "status" text not null, "expiresAt" date not null, "inviterId" text not null references "user" ("id"));
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
title="New Organization"
|
||||
description="Create a new organization to collaborate with your team"
|
||||
>
|
||||
<template #default>
|
||||
<UButton icon="i-heroicons-plus" @click="open = true"> Organisation erstellen </UButton>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<UForm :state="state" :schema="organizationSchema" class="space-y-4" @submit="onCreateOrganizationSubmit">
|
||||
<UFormField label="Name" name="name">
|
||||
<UInput v-model="state.name" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Slug" name="slug">
|
||||
<UInput v-model="state.slug" class="w-full" @input="isSlugEdited = true" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Logo (optional)" name="logo">
|
||||
<input type="file" accept="image/*" @change="handleLogoChange" />
|
||||
<img v-if="state.logo" :src="state.logo" alt="Logo preview" class="w-16 h-16 object-cover mt-2" />
|
||||
</UFormField>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton type="submit" :loading="loading">
|
||||
{{ loading ? 'Creating...' : 'Create' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</UForm>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { organizationSchema, type OrganizationSchema } from '~/types/schemas'
|
||||
|
||||
const { createOrganization } = useOrganizationStore()
|
||||
const open = ref(false)
|
||||
const loading = ref(false)
|
||||
const isSlugEdited = ref(false)
|
||||
|
||||
const state = reactive<Partial<OrganizationSchema>>({
|
||||
name: undefined,
|
||||
slug: undefined,
|
||||
logo: undefined
|
||||
})
|
||||
|
||||
watch(
|
||||
() => state.name,
|
||||
(newName) => {
|
||||
if (!isSlugEdited.value) {
|
||||
state.slug = (newName ?? '').trim().toLowerCase().replace(/\s+/g, '-')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val) {
|
||||
state.name = ''
|
||||
state.slug = ''
|
||||
state.logo = undefined
|
||||
isSlugEdited.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogoChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input?.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
state.logo = reader.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
async function onCreateOrganizationSubmit() {
|
||||
loading.value = true
|
||||
|
||||
if (!state.name || !state.slug) return
|
||||
|
||||
await createOrganization(state.name, state.slug, state.logo)
|
||||
loading.value = false
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,121 +0,0 @@
|
||||
<template>
|
||||
<UModal v-model:open="open" title="Mitglied einladen" description="Laden Sie ein Mitglied zu Ihrer Organisation ein">
|
||||
<UButton v-if="canInviteMembers" icon="i-lucide-mail-plus" variant="outline" size="sm" @click="open = true">
|
||||
Mitglied einladen
|
||||
</UButton>
|
||||
|
||||
<template #body>
|
||||
<UForm :state="form" class="space-y-4" @submit="handleSubmit">
|
||||
<UFormField label="E-Mail" name="email">
|
||||
<UInput v-model="form.email" type="email" placeholder="E-Mail" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Rolle" name="role">
|
||||
<USelect
|
||||
v-model="form.role"
|
||||
:items="availableRoles"
|
||||
placeholder="Rolle auswählen"
|
||||
value-key="value"
|
||||
class="w-full"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon :name="option.icon" :class="`text-${option.color}-500`" />
|
||||
<div>
|
||||
<div class="font-medium">{{ option.label }}</div>
|
||||
<div class="text-xs text-gray-500">{{ option.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USelect>
|
||||
</UFormField>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UButton type="submit" :loading="loading">
|
||||
{{ loading ? 'Einladen...' : 'Einladen' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</UForm>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ROLES, type LegalRole } from '~/server/utils/permissions'
|
||||
|
||||
const { canInviteMembers } = usePermissions()
|
||||
const { inviteMember } = useOrganizationStore()
|
||||
|
||||
const open = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
email: '',
|
||||
role: ROLES.EMPLOYEE as LegalRole
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const availableRoles = computed(() => {
|
||||
return Object.values(ROLES).map((role) => {
|
||||
const roleInfo = getRoleInfo(role)
|
||||
return {
|
||||
label: roleInfo.name,
|
||||
value: role,
|
||||
description: roleInfo.description,
|
||||
color: roleInfo.color,
|
||||
icon: roleInfo.icon
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function getRoleInfo(role: LegalRole) {
|
||||
const roleInfo = {
|
||||
[ROLES.EMPLOYER]: {
|
||||
name: t('roles.employer'),
|
||||
description: 'Kann Anträge genehmigen und Vereinbarungen unterzeichnen',
|
||||
color: 'blue',
|
||||
icon: 'i-lucide-briefcase'
|
||||
},
|
||||
[ROLES.EMPLOYEE]: {
|
||||
name: t('roles.employee'),
|
||||
description: 'Kann eigene Anträge einsehen und kommentieren',
|
||||
color: 'green',
|
||||
icon: 'i-lucide-user'
|
||||
},
|
||||
[ROLES.WORKS_COUNCIL_MEMBER]: {
|
||||
name: t('roles.worksCouncilMember'),
|
||||
description: 'Kann Anträge prüfen und Vereinbarungen unterzeichnen',
|
||||
color: 'purple',
|
||||
icon: 'i-lucide-users'
|
||||
},
|
||||
[ROLES.ADMIN]: {
|
||||
name: t('roles.admin'),
|
||||
description: 'Vollzugriff auf Organisationsverwaltung',
|
||||
color: 'red',
|
||||
icon: 'i-lucide-settings'
|
||||
}
|
||||
}
|
||||
|
||||
return roleInfo[role] || { name: role, description: '', color: 'gray', icon: 'i-lucide-circle' }
|
||||
}
|
||||
|
||||
watch(open, (val: boolean) => {
|
||||
if (val) {
|
||||
form.value = {
|
||||
email: '',
|
||||
role: ROLES.EMPLOYEE
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await inviteMember(form.value.email, form.value.role)
|
||||
open.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -80,13 +80,13 @@ const isOpen = computed({
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const { notifications, fetchNotifications, handleNotificationClick } = useNotification()
|
||||
|
||||
watch(isOpen, async (newValue) => {
|
||||
if (newValue) {
|
||||
await fetchNotifications()
|
||||
}
|
||||
})
|
||||
// const { notifications, fetchNotifications, handleNotificationClick } = useNotification()
|
||||
//
|
||||
// watch(isOpen, async (newValue) => {
|
||||
// if (newValue) {
|
||||
// await fetchNotifications()
|
||||
// }
|
||||
// })
|
||||
|
||||
function onNotificationClick(notification: NotificationDto) {
|
||||
handleNotificationClick(notification)
|
||||
|
||||
@@ -60,13 +60,14 @@ const colors = [
|
||||
]
|
||||
const neutrals = ['slate', 'gray', 'zinc', 'neutral', 'stone']
|
||||
|
||||
const { user: betterAuthUser, signOut } = await useAuth()
|
||||
const userStore = useUserStore()
|
||||
const { user: keyCloakUser } = storeToRefs(userStore)
|
||||
|
||||
const user = ref({
|
||||
name: betterAuthUser.value?.name,
|
||||
name: keyCloakUser.value.name,
|
||||
avatar: {
|
||||
src: '/_nuxt/public/favicon.ico',
|
||||
alt: betterAuthUser.value?.name
|
||||
alt: keyCloakUser.value.name
|
||||
}
|
||||
})
|
||||
|
||||
@@ -178,7 +179,7 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
icon: 'i-lucide-log-out',
|
||||
async onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
await signOut({ redirectTo: '/' })
|
||||
await navigateTo('/auth/logout', { external: true })
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
type CreateApplicationFormDto,
|
||||
type ApplicationFormDto,
|
||||
type PagedApplicationFormDto,
|
||||
ResponseError
|
||||
} from '~/.api-client'
|
||||
import { type CreateApplicationFormDto, type ApplicationFormDto, type PagedApplicationFormDto } from '~/.api-client'
|
||||
import { useApplicationFormApi } from './useApplicationFormApi'
|
||||
|
||||
export function useApplicationForm() {
|
||||
@@ -15,11 +10,7 @@ export function useApplicationForm() {
|
||||
try {
|
||||
return await applicationFormApi.createApplicationForm(createApplicationFormDto)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ResponseError) {
|
||||
console.error('Failed creating application form:', e.response)
|
||||
} else {
|
||||
console.error('Failed creating application form:', e)
|
||||
}
|
||||
console.error('Failed creating application form:', e)
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
@@ -28,11 +19,7 @@ export function useApplicationForm() {
|
||||
try {
|
||||
return await applicationFormApi.getAllApplicationForms(organizationId)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ResponseError) {
|
||||
console.error('Failed retrieving application forms:', e.response)
|
||||
} else {
|
||||
console.error('Failed retrieving application forms:', e)
|
||||
}
|
||||
console.error('Failed retrieving application forms:', e, JSON.stringify(e))
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
@@ -41,11 +28,7 @@ export function useApplicationForm() {
|
||||
try {
|
||||
return await applicationFormApi.getApplicationFormById(id)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ResponseError) {
|
||||
console.error(`Failed retrieving application form with ID ${id}:`, e.response)
|
||||
} else {
|
||||
console.error(`Failed retrieving application form with ID ${id}:`, e)
|
||||
}
|
||||
console.error(`Failed retrieving application form with ID ${id}:`, e)
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
@@ -61,11 +44,7 @@ export function useApplicationForm() {
|
||||
try {
|
||||
return await applicationFormApi.updateApplicationForm(id, applicationFormDto)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ResponseError) {
|
||||
console.error(`Failed updating application form with ID ${id}:`, e.response)
|
||||
} else {
|
||||
console.error(`Failed updating application form with ID ${id}:`, e)
|
||||
}
|
||||
console.error(`Failed updating application form with ID ${id}:`, e)
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
@@ -74,11 +53,7 @@ export function useApplicationForm() {
|
||||
try {
|
||||
return await applicationFormApi.deleteApplicationFormById(id)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ResponseError) {
|
||||
console.error(`Failed deleting application form with ID ${id}:`, e.response)
|
||||
} else {
|
||||
console.error(`Failed deleting application form with ID ${id}:`, e)
|
||||
}
|
||||
console.error(`Failed deleting application form with ID ${id}:`, e)
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
@@ -91,11 +66,7 @@ export function useApplicationForm() {
|
||||
try {
|
||||
return await applicationFormApi.submitApplicationForm(id)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ResponseError) {
|
||||
console.error(`Failed submitting application form with ID ${id}:`, e.response)
|
||||
} else {
|
||||
console.error(`Failed submitting application form with ID ${id}:`, e)
|
||||
}
|
||||
console.error(`Failed submitting application form with ID ${id}:`, e)
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@ import {
|
||||
type PagedApplicationFormDto
|
||||
} from '~/.api-client'
|
||||
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
|
||||
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
|
||||
|
||||
export function useApplicationFormApi() {
|
||||
const appBaseUrl = useRuntimeConfig().app.baseURL
|
||||
const { serverApiBaseUrl, serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
const { jwt } = useAuth()
|
||||
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
|
||||
const basePath = withoutTrailingSlash(
|
||||
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath)
|
||||
cleanDoubleSlashes(
|
||||
import.meta.client
|
||||
? appBaseUrl + clientProxyBasePath
|
||||
: useRequestURL().origin + clientProxyBasePath + serverApiBasePath
|
||||
)
|
||||
)
|
||||
|
||||
const applicationFormApiClient = new ApplicationFormApi(
|
||||
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } })
|
||||
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
|
||||
)
|
||||
|
||||
async function createApplicationForm(
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useApplicationFormTemplateApi } from './useApplicationFormTemplateApi'
|
||||
|
||||
const currentApplicationForm: Ref<ApplicationFormDto | undefined> = ref()
|
||||
|
||||
export function useApplicationFormTemplate() {
|
||||
const applicationFormApi = useApplicationFormTemplateApi()
|
||||
export async function useApplicationFormTemplate() {
|
||||
const applicationFormApi = await useApplicationFormTemplateApi()
|
||||
|
||||
async function createApplicationFormTemplate(
|
||||
createApplicationFormDto: CreateApplicationFormDto
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { ApplicationFormTemplateApi, Configuration } from '../../.api-client'
|
||||
import type { CreateApplicationFormDto, ApplicationFormDto, PagedApplicationFormDto } from '~/.api-client'
|
||||
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
|
||||
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
|
||||
|
||||
export function useApplicationFormTemplateApi() {
|
||||
export async function useApplicationFormTemplateApi() {
|
||||
const appBaseUrl = useRuntimeConfig().app.baseURL
|
||||
const { serverApiBaseUrl, serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
const { jwt } = useAuth()
|
||||
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
|
||||
const basePath = withoutTrailingSlash(
|
||||
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath)
|
||||
cleanDoubleSlashes(
|
||||
import.meta.client
|
||||
? appBaseUrl + clientProxyBasePath
|
||||
: useRequestURL().origin + clientProxyBasePath + serverApiBasePath
|
||||
)
|
||||
)
|
||||
|
||||
const applicationFormApiClient = new ApplicationFormTemplateApi(
|
||||
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } })
|
||||
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
|
||||
)
|
||||
|
||||
async function createApplicationFormTemplate(
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
import type { SignInSchema, SignUpSchema } from '~/types/schemas'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { useAuthClient } from '~/composables/auth/useAuthClient'
|
||||
import { useAuthState } from '~/composables/auth/useAuthState'
|
||||
|
||||
export function useAuthActions() {
|
||||
const { client } = useAuthClient()
|
||||
const { session, user } = useAuthState()
|
||||
const { createUser } = useUser()
|
||||
const toast = useToast()
|
||||
|
||||
async function signOut({ redirectTo }: { redirectTo?: RouteLocationRaw } = {}) {
|
||||
const res = await client.signOut()
|
||||
if (res.error) {
|
||||
console.error('Error signing out:', res.error)
|
||||
return res
|
||||
}
|
||||
session.value = null
|
||||
user.value = null
|
||||
if (redirectTo) {
|
||||
await navigateTo(redirectTo, { external: true })
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function signUp(payload: FormSubmitEvent<SignUpSchema>) {
|
||||
await client.signUp.email(
|
||||
{
|
||||
email: payload.data.email,
|
||||
password: payload.data.password,
|
||||
name: payload.data.name
|
||||
},
|
||||
{
|
||||
onRequest: () => {
|
||||
console.log('Sending register request')
|
||||
},
|
||||
onResponse: () => {
|
||||
console.log('Receiving register response')
|
||||
},
|
||||
onSuccess: async (ctx) => {
|
||||
console.log('Successfully registered!')
|
||||
|
||||
// Create user in backend after successful Better Auth registration
|
||||
try {
|
||||
console.log('Creating user in backend...', ctx.data)
|
||||
await createUser({
|
||||
id: ctx.data.user.id,
|
||||
name: ctx.data.user.name,
|
||||
status: 'ACTIVE'
|
||||
})
|
||||
console.log('User created in backend successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to create user in backend:', error)
|
||||
toast.add({
|
||||
title: 'Warning',
|
||||
description: 'Account created but there was an issue with backend setup. Please contact support.',
|
||||
color: 'warning'
|
||||
})
|
||||
}
|
||||
|
||||
await navigateTo('/')
|
||||
},
|
||||
onError: async (ctx) => {
|
||||
console.log(ctx.error.message)
|
||||
toast.add({
|
||||
title: 'Fehler bei der Registrierung',
|
||||
description: ctx.error.message,
|
||||
color: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function signIn(payload: FormSubmitEvent<SignInSchema>) {
|
||||
await client.signIn.email(
|
||||
{
|
||||
email: payload.data.email,
|
||||
password: payload.data.password
|
||||
},
|
||||
{
|
||||
onRequest: () => {
|
||||
console.log('Sending login request')
|
||||
},
|
||||
onSuccess: async () => {
|
||||
console.log('Successfully logged in!')
|
||||
await navigateTo('/')
|
||||
},
|
||||
onError: (ctx) => {
|
||||
console.log(ctx.error.message)
|
||||
toast.add({
|
||||
title: 'Fehler bei der Anmeldung',
|
||||
description: ctx.error.message,
|
||||
color: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
signOut,
|
||||
signUp,
|
||||
signIn
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { createAuthClient } from 'better-auth/vue'
|
||||
import { jwtClient, organizationClient } from 'better-auth/client/plugins'
|
||||
import {
|
||||
accessControl,
|
||||
adminRole,
|
||||
employeeRole,
|
||||
employerRole,
|
||||
ownerRole,
|
||||
ROLES,
|
||||
worksCouncilMemberRole
|
||||
} from '~/server/utils/permissions'
|
||||
|
||||
export function useAuthClient() {
|
||||
const url = useRequestURL()
|
||||
const headers = import.meta.server ? useRequestHeaders() : undefined
|
||||
|
||||
const client = createAuthClient({
|
||||
baseURL: url.origin,
|
||||
fetchOptions: {
|
||||
headers
|
||||
},
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
organizationClient({
|
||||
// Pass the same access control instance and roles to client
|
||||
ac: accessControl,
|
||||
roles: {
|
||||
[ROLES.EMPLOYER]: employerRole,
|
||||
[ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole,
|
||||
[ROLES.EMPLOYEE]: employeeRole,
|
||||
[ROLES.ADMIN]: adminRole,
|
||||
[ROLES.OWNER]: ownerRole
|
||||
}
|
||||
}),
|
||||
jwtClient()
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
client
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { ClientOptions, InferSessionFromClient, InferUserFromClient } from 'better-auth/client'
|
||||
import type { RuntimeAuthConfig } from '~/types/auth'
|
||||
import { defu } from 'defu'
|
||||
import { useAuthClient } from '~/composables/auth/useAuthClient'
|
||||
|
||||
// Global state for auth
|
||||
const session = ref<InferSessionFromClient<ClientOptions> | null>(null)
|
||||
const user = ref<InferUserFromClient<ClientOptions> | null>(null)
|
||||
const sessionFetching = import.meta.server ? ref(false) : ref(false)
|
||||
const jwt = ref<string | null>(null)
|
||||
const organizations = ref<
|
||||
{
|
||||
id: string
|
||||
name: string
|
||||
createdAt: Date
|
||||
slug: string
|
||||
metadata?: Record<string, unknown>
|
||||
logo?: string | null
|
||||
}[]
|
||||
>([])
|
||||
const selectedOrganization = ref<{
|
||||
id: string
|
||||
name: string
|
||||
createdAt: Date
|
||||
slug: string
|
||||
metadata?: Record<string, unknown>
|
||||
logo?: string | null
|
||||
} | null>(null)
|
||||
const activeMember = ref<{ role: string } | null>(null)
|
||||
|
||||
export function useAuthState() {
|
||||
const { client } = useAuthClient()
|
||||
const route = useRoute()
|
||||
|
||||
const options = defu(useRuntimeConfig().public.auth as Partial<RuntimeAuthConfig>, {
|
||||
redirectUserTo: '/',
|
||||
redirectGuestTo: '/login'
|
||||
})
|
||||
|
||||
const headers = import.meta.server ? useRequestHeaders() : undefined
|
||||
|
||||
async function fetchSession(targetPath?: string) {
|
||||
if (sessionFetching.value) {
|
||||
console.log('already fetching session')
|
||||
return
|
||||
}
|
||||
sessionFetching.value = true
|
||||
const { data } = await client.getSession({
|
||||
fetchOptions: {
|
||||
headers
|
||||
}
|
||||
})
|
||||
session.value = data?.session || null
|
||||
user.value = data?.user || null
|
||||
sessionFetching.value = false
|
||||
|
||||
// Only fetch JWT and organizations if we have a session and not on public routes
|
||||
if (session.value && !isPublicPath(targetPath)) {
|
||||
await fetchJwtAndOrganizations()
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchJwtAndOrganizations() {
|
||||
// Fetch JWT
|
||||
const tokenResult = await client.token()
|
||||
jwt.value = tokenResult.data?.token ?? null
|
||||
|
||||
// Fetch organization
|
||||
const orgResult = await client.organization.list({
|
||||
fetchOptions: {
|
||||
headers
|
||||
}
|
||||
})
|
||||
organizations.value = orgResult.data ?? []
|
||||
|
||||
if (!selectedOrganization.value && organizations.value.length > 0) {
|
||||
selectedOrganization.value = organizations.value[0]
|
||||
}
|
||||
|
||||
// Fetch active member
|
||||
const activeMemberResult = await client.organization.getActiveMember({
|
||||
fetchOptions: {
|
||||
headers
|
||||
}
|
||||
})
|
||||
activeMember.value = activeMemberResult.data || null
|
||||
}
|
||||
|
||||
function isPublicPath(path?: string) {
|
||||
const finalPath = path ?? route.path
|
||||
const publicRoutes = ['/login', '/signup', '/accept-invitation']
|
||||
return publicRoutes.some((path) => finalPath.startsWith(path))
|
||||
}
|
||||
|
||||
// Watch organization changes
|
||||
watch(
|
||||
() => selectedOrganization.value,
|
||||
async (newValue) => {
|
||||
if (newValue) {
|
||||
await client.organization.setActive({
|
||||
organizationId: newValue?.id
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Client-side session listening
|
||||
if (import.meta.client) {
|
||||
client.$store.listen('$sessionSignal', async (signal) => {
|
||||
if (!signal) return
|
||||
await fetchSession()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
sessionFetching,
|
||||
jwt,
|
||||
organizations,
|
||||
selectedOrganization,
|
||||
activeMember,
|
||||
options,
|
||||
fetchSession,
|
||||
isPublicPath,
|
||||
loggedIn: computed(() => !!session.value)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import { CommentApi, Configuration, type CommentDto, type CreateCommentDto, type PagedCommentDto } from '~/.api-client'
|
||||
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
|
||||
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
|
||||
|
||||
export function useCommentApi() {
|
||||
const appBaseUrl = useRuntimeConfig().app.baseURL
|
||||
const { serverApiBaseUrl, serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
const { jwt } = useAuth()
|
||||
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
|
||||
const basePath = withoutTrailingSlash(
|
||||
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath)
|
||||
cleanDoubleSlashes(
|
||||
import.meta.client
|
||||
? appBaseUrl + clientProxyBasePath
|
||||
: useRequestURL().origin + clientProxyBasePath + serverApiBasePath
|
||||
)
|
||||
)
|
||||
|
||||
const commentApiClient = new CommentApi(
|
||||
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } })
|
||||
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
|
||||
)
|
||||
|
||||
async function createComment(
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { CreateCommentDto, CommentDto } from '~/.api-client'
|
||||
export function useCommentTextarea(applicationFormId: string) {
|
||||
const commentStore = useCommentStore()
|
||||
const { createComment, updateComment } = commentStore
|
||||
const { user } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
const { user } = storeToRefs(userStore)
|
||||
const isEditingComment = ref(false)
|
||||
const currentEditedComment = ref<CommentDto | null>(null)
|
||||
const commentTextAreaValue = ref('')
|
||||
@@ -51,7 +52,7 @@ export function useCommentTextarea(applicationFormId: string) {
|
||||
}
|
||||
|
||||
function isCommentByUser(comment: CommentDto) {
|
||||
return comment.createdBy.id === user.value?.id
|
||||
return comment.createdBy.keycloakId === user.value?.keycloakId
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,5 +2,3 @@ export { useApplicationFormTemplate } from './applicationFormTemplate/useApplica
|
||||
export { useApplicationForm } from './applicationForm/useApplicationForm'
|
||||
export { useNotification } from './notification/useNotification'
|
||||
export { useNotificationApi } from './notification/useNotificationApi'
|
||||
export { useUser } from './user/useUser'
|
||||
export { useUserApi } from './user/useUserApi'
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type {
|
||||
VerifySignatureHashAlgorithmEnum,
|
||||
VerifySignatureResponseDto,
|
||||
SignPdfHashHashAlgorithmEnum
|
||||
} from '~/.api-client-middleware'
|
||||
import { useMiddlewareApi } from '~/composables/middleware/useMiddlewareApi'
|
||||
|
||||
export function useMiddleware() {
|
||||
const middlewareApi = useMiddlewareApi()
|
||||
|
||||
async function signPdfHash(document: Blob, certificateId: string, hashAlgorithm?: SignPdfHashHashAlgorithmEnum) {
|
||||
try {
|
||||
return middlewareApi.signPdfHash(document, certificateId, hashAlgorithm)
|
||||
} catch (e: unknown) {
|
||||
console.error('Failed signing PDF hash:', e)
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySignature(
|
||||
document: Blob,
|
||||
signature: string,
|
||||
certificateId?: string,
|
||||
hashAlgorithm?: VerifySignatureHashAlgorithmEnum
|
||||
): Promise<VerifySignatureResponseDto> {
|
||||
try {
|
||||
return await middlewareApi.verifySignature(document, signature, certificateId, hashAlgorithm)
|
||||
} catch (e: unknown) {
|
||||
console.error('Failed verifying signature:', e)
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
signPdfHash,
|
||||
verifySignature
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
|
||||
import {
|
||||
SmartCardApi,
|
||||
SignatureApi,
|
||||
Configuration,
|
||||
type VerifySignatureHashAlgorithmEnum,
|
||||
type VerifySignatureResponseDto,
|
||||
type SignPdfHashHashAlgorithmEnum
|
||||
} from '~/.api-client-middleware'
|
||||
|
||||
export function useMiddlewareApi() {
|
||||
const appBaseUrl = useRuntimeConfig().app.baseURL
|
||||
const { serverApiBaseUrl, serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
const { jwt } = useAuth()
|
||||
|
||||
const basePath = withoutTrailingSlash(
|
||||
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath)
|
||||
)
|
||||
|
||||
const smartCardApiClient = new SmartCardApi(
|
||||
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } })
|
||||
)
|
||||
|
||||
const signatureApiClient = new SignatureApi(
|
||||
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } })
|
||||
)
|
||||
|
||||
async function signPdfHash(document: Blob, certificateId: string, hashAlgorithm?: SignPdfHashHashAlgorithmEnum) {
|
||||
return signatureApiClient.signPdfHash({ document, certificateId, hashAlgorithm })
|
||||
}
|
||||
|
||||
async function verifySignature(
|
||||
document: Blob,
|
||||
signature: string,
|
||||
certificateId?: string,
|
||||
hashAlgorithm?: VerifySignatureHashAlgorithmEnum
|
||||
): Promise<VerifySignatureResponseDto> {
|
||||
return signatureApiClient.verifySignature({ document, signature, certificateId, hashAlgorithm })
|
||||
}
|
||||
|
||||
return {
|
||||
signPdfHash,
|
||||
verifySignature
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,22 @@ import {
|
||||
type CreateNotificationDto
|
||||
} from '~/.api-client'
|
||||
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
|
||||
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
|
||||
|
||||
export function useNotificationApi() {
|
||||
const appBaseUrl = useRuntimeConfig().app.baseURL
|
||||
const { serverApiBaseUrl, serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
const { jwt } = useAuth()
|
||||
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
|
||||
const basePath = withoutTrailingSlash(
|
||||
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath)
|
||||
cleanDoubleSlashes(
|
||||
import.meta.client
|
||||
? appBaseUrl + clientProxyBasePath
|
||||
: useRequestURL().origin + clientProxyBasePath + serverApiBasePath
|
||||
)
|
||||
)
|
||||
|
||||
const notificationApiClient = new NotificationApi(
|
||||
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } })
|
||||
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
|
||||
)
|
||||
|
||||
async function createNotification(createNotificationDto: CreateNotificationDto): Promise<NotificationDto> {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { LegalRole } from '~/server/utils/permissions'
|
||||
import type { ListMembersOptions } from '~/types/auth'
|
||||
|
||||
export function useOrganizationApi() {
|
||||
const { organization } = useAuth()
|
||||
|
||||
async function createOrganization(name: string, slug: string, logo?: string) {
|
||||
return organization.create({ name, slug, logo })
|
||||
}
|
||||
|
||||
async function deleteOrganization(organizationId: string) {
|
||||
return organization.delete({ organizationId })
|
||||
}
|
||||
|
||||
async function getInvitation(invitationId: string) {
|
||||
return organization.getInvitation({ query: { id: invitationId } })
|
||||
}
|
||||
|
||||
async function listInvitations(organizationId?: string) {
|
||||
return organization.listInvitations(organizationId ? { query: { organizationId: organizationId } } : undefined)
|
||||
}
|
||||
|
||||
async function inviteMember(email: string, role: LegalRole) {
|
||||
return organization.inviteMember({ email, role })
|
||||
}
|
||||
|
||||
async function removeMember(memberIdOrEmail: string) {
|
||||
return organization.removeMember({ memberIdOrEmail })
|
||||
}
|
||||
|
||||
async function acceptInvitation(invitationId: string) {
|
||||
return organization.acceptInvitation({ invitationId })
|
||||
}
|
||||
|
||||
async function cancelSentInvitation(invitationId: string) {
|
||||
return organization.cancelInvitation({ invitationId })
|
||||
}
|
||||
|
||||
async function rejectInvitation(invitationId: string) {
|
||||
return organization.rejectInvitation({ invitationId })
|
||||
}
|
||||
|
||||
async function loadOrganizations() {
|
||||
return organization.list()
|
||||
}
|
||||
|
||||
async function checkSlugAvailability(slug: string) {
|
||||
return organization.checkSlug({ slug })
|
||||
}
|
||||
|
||||
async function setActiveOrganization(organizationId: string) {
|
||||
return organization.setActive({ organizationId })
|
||||
}
|
||||
|
||||
async function listMembers(options?: ListMembersOptions) {
|
||||
return organization.listMembers(options)
|
||||
}
|
||||
|
||||
return {
|
||||
createOrganization,
|
||||
deleteOrganization,
|
||||
getInvitation,
|
||||
listInvitations,
|
||||
inviteMember,
|
||||
removeMember,
|
||||
acceptInvitation,
|
||||
cancelSentInvitation,
|
||||
rejectInvitation,
|
||||
loadOrganizations,
|
||||
checkSlugAvailability,
|
||||
setActiveOrganization,
|
||||
listMembers
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// Copied from https://github.com/atinux/nuxthub-better-auth
|
||||
|
||||
import { useAuthState } from '~/composables/auth/useAuthState'
|
||||
import { useAuthActions } from '~/composables/auth/useAuthActions'
|
||||
import { useAuthClient } from '~/composables/auth/useAuthClient'
|
||||
|
||||
export function useAuth() {
|
||||
const authState = useAuthState()
|
||||
const authActions = useAuthActions()
|
||||
const { client } = useAuthClient()
|
||||
|
||||
return {
|
||||
...authState,
|
||||
...authActions,
|
||||
client,
|
||||
deleteUser: client.deleteUser,
|
||||
organization: client.organization
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { ROLES, type LegalRole } from '~/server/utils/permissions'
|
||||
|
||||
export function usePermissions() {
|
||||
const { organization, activeMember } = useAuth()
|
||||
|
||||
const currentRole = computed((): LegalRole | null => {
|
||||
return (activeMember.value?.role as LegalRole) || null
|
||||
})
|
||||
|
||||
const hasPermission = (permissions: Record<string, string[]>): boolean => {
|
||||
if (!currentRole.value) return false
|
||||
|
||||
return organization.checkRolePermission({
|
||||
permissions,
|
||||
role: currentRole.value
|
||||
})
|
||||
}
|
||||
|
||||
// Specific permission helpers
|
||||
const canCreateApplicationForm = computed(() =>
|
||||
hasPermission({ application_form: ["create"] })
|
||||
)
|
||||
|
||||
const canApproveApplicationForm = computed(() =>
|
||||
hasPermission({ application_form: ["approve"] })
|
||||
)
|
||||
|
||||
const canSignAgreement = computed(() =>
|
||||
hasPermission({ agreement: ["sign"] })
|
||||
)
|
||||
|
||||
const canInviteMembers = computed(() =>
|
||||
hasPermission({ invitation: ["create"] })
|
||||
)
|
||||
|
||||
const canManageOrganization = computed(() =>
|
||||
hasPermission({ organization: ["update"] })
|
||||
)
|
||||
|
||||
// Role checks
|
||||
const isEmployer = computed(() => currentRole.value === ROLES.EMPLOYER)
|
||||
const isEmployee = computed(() => currentRole.value === ROLES.EMPLOYEE)
|
||||
const isWorksCouncilMember = computed(() => currentRole.value === ROLES.WORKS_COUNCIL_MEMBER)
|
||||
const isAdmin = computed(() => currentRole.value === ROLES.ADMIN)
|
||||
const isOwner = computed(() => currentRole.value === ROLES.OWNER)
|
||||
|
||||
const getCurrentRoleInfo = () => {
|
||||
const roleInfo = {
|
||||
[ROLES.EMPLOYER]: {
|
||||
name: 'Arbeitgeber',
|
||||
description: 'Kann Anträge genehmigen und Vereinbarungen unterzeichnen',
|
||||
color: 'blue',
|
||||
icon: 'i-lucide-briefcase'
|
||||
},
|
||||
[ROLES.EMPLOYEE]: {
|
||||
name: 'Arbeitnehmer',
|
||||
description: 'Kann eigene Anträge einsehen und kommentieren',
|
||||
color: 'green',
|
||||
icon: 'i-lucide-user'
|
||||
},
|
||||
[ROLES.WORKS_COUNCIL_MEMBER]: {
|
||||
name: 'Betriebsrat',
|
||||
description: 'Kann Anträge prüfen und Vereinbarungen unterzeichnen',
|
||||
color: 'purple',
|
||||
icon: 'i-lucide-users'
|
||||
},
|
||||
[ROLES.ADMIN]: {
|
||||
name: 'Administrator',
|
||||
description: 'Vollzugriff auf Organisationsverwaltung',
|
||||
color: 'red',
|
||||
icon: 'i-lucide-settings'
|
||||
},
|
||||
[ROLES.OWNER]: {
|
||||
name: 'Eigentümer',
|
||||
description: 'Vollzugriff und Organisationsbesitz',
|
||||
color: 'yellow',
|
||||
icon: 'i-lucide-crown'
|
||||
}
|
||||
}
|
||||
|
||||
return currentRole.value && currentRole.value in roleInfo ? roleInfo[currentRole.value as LegalRole] : null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentRole,
|
||||
activeMember,
|
||||
|
||||
// Permission checks
|
||||
hasPermission,
|
||||
|
||||
// Role checks
|
||||
isEmployer,
|
||||
isEmployee,
|
||||
isWorksCouncilMember,
|
||||
isAdmin,
|
||||
isOwner,
|
||||
|
||||
// Computed permissions
|
||||
canCreateApplicationForm,
|
||||
canApproveApplicationForm,
|
||||
canSignAgreement,
|
||||
canInviteMembers,
|
||||
canManageOrganization,
|
||||
|
||||
// Utilities
|
||||
getCurrentRoleInfo,
|
||||
ROLES
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import {
|
||||
type CreateUserDto,
|
||||
type UserDto,
|
||||
ResponseError
|
||||
} from '~/.api-client'
|
||||
import { useUserApi } from './useUserApi'
|
||||
|
||||
export function useUser() {
|
||||
const userApi = useUserApi()
|
||||
|
||||
async function createUser(createUserDto: CreateUserDto): Promise<UserDto> {
|
||||
try {
|
||||
return await userApi.createUser(createUserDto)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ResponseError) {
|
||||
console.error('Failed creating user:', e.response)
|
||||
} else {
|
||||
console.error('Failed creating user:', e)
|
||||
}
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserById(id: string): Promise<UserDto | null> {
|
||||
try {
|
||||
return await userApi.getUserById(id)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ResponseError && e.response.status === 404) {
|
||||
return null
|
||||
}
|
||||
if (e instanceof ResponseError) {
|
||||
console.error(`Failed retrieving user with ID ${id}:`, e.response)
|
||||
} else {
|
||||
console.error(`Failed retrieving user with ID ${id}:`, e)
|
||||
}
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(id: string, userDto?: UserDto): Promise<UserDto> {
|
||||
try {
|
||||
return await userApi.updateUser(id, userDto)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ResponseError) {
|
||||
console.error(`Failed updating user with ID ${id}:`, e.response)
|
||||
} else {
|
||||
console.error(`Failed updating user with ID ${id}:`, e)
|
||||
}
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id: string): Promise<void> {
|
||||
try {
|
||||
return await userApi.deleteUser(id)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ResponseError) {
|
||||
console.error(`Failed deleting user with ID ${id}:`, e.response)
|
||||
} else {
|
||||
console.error(`Failed deleting user with ID ${id}:`, e)
|
||||
}
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createUser,
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { UserApi, Configuration, type CreateUserDto, type UserDto } from '~/.api-client'
|
||||
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
|
||||
import { useAuthState } from '~/composables/auth/useAuthState'
|
||||
|
||||
export function useUserApi() {
|
||||
const appBaseUrl = useRuntimeConfig().app.baseURL
|
||||
const { serverApiBaseUrl, serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
const { jwt } = useAuthState()
|
||||
|
||||
const basePath = withoutTrailingSlash(
|
||||
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath)
|
||||
)
|
||||
|
||||
// Track changing JWT of user who accepts the invitation
|
||||
const userApiClient = computed(
|
||||
() =>
|
||||
new UserApi(new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } }))
|
||||
)
|
||||
|
||||
async function createUser(createUserDto: CreateUserDto): Promise<UserDto> {
|
||||
return userApiClient.value.createUser({ createUserDto })
|
||||
}
|
||||
|
||||
async function getUserById(id: string): Promise<UserDto> {
|
||||
return userApiClient.value.getUserById({ id })
|
||||
}
|
||||
|
||||
async function updateUser(id: string, userDto?: UserDto): Promise<UserDto> {
|
||||
return userApiClient.value.updateUser({ id, userDto })
|
||||
}
|
||||
|
||||
async function deleteUser(id: string): Promise<void> {
|
||||
return userApiClient.value.deleteUser({ id })
|
||||
}
|
||||
|
||||
return {
|
||||
createUser,
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,5 @@
|
||||
<template>
|
||||
<div class="h-screen flex items-center justify-center overlay">
|
||||
<UButton
|
||||
icon="i-lucide-chevron-left"
|
||||
to="/"
|
||||
size="xl"
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
class="absolute left-8 top-8 rounded-full"
|
||||
/>
|
||||
|
||||
<UPageCard variant="subtle" class="max-w-sm w-full">
|
||||
<slot />
|
||||
</UPageCard>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
<template #footer="{ collapsed }">
|
||||
<UserMenu :collapsed="collapsed" />
|
||||
<UButton @click="copyAccessTokenToClipboard">📋</UButton>
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
|
||||
@@ -41,13 +42,25 @@ const open = ref(false)
|
||||
const isNotificationsSlideoverOpen = ref(false)
|
||||
const { unreadCount, fetchUnreadCount, startPeriodicRefresh } = useNotification()
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchUnreadCount()
|
||||
startPeriodicRefresh()
|
||||
})
|
||||
// onMounted(async () => {
|
||||
// await fetchUnreadCount()
|
||||
// startPeriodicRefresh()
|
||||
// })
|
||||
|
||||
provide('notificationState', {
|
||||
isNotificationsSlideoverOpen,
|
||||
unreadCount
|
||||
})
|
||||
|
||||
async function copyAccessTokenToClipboard() {
|
||||
const { session } = useUserSession()
|
||||
console.log('Access Token :', session.value?.jwt?.accessToken)
|
||||
const accessToken = session.value?.jwt?.accessToken
|
||||
if (accessToken) {
|
||||
navigator.clipboard.writeText(accessToken)
|
||||
console.log('Access token copied to clipboard')
|
||||
} else {
|
||||
console.warn('No access token found in session')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,75 +1,14 @@
|
||||
// Copied from https://github.com/atinux/nuxthub-better-auth
|
||||
|
||||
import { defu } from 'defu'
|
||||
import type { RouteLocationNormalized } from '#vue-router'
|
||||
|
||||
type MiddlewareOptions =
|
||||
| false
|
||||
| {
|
||||
/**
|
||||
* Only apply auth middleware to guest or user
|
||||
*/
|
||||
only?: 'guest' | 'user'
|
||||
/**
|
||||
* Redirect authenticated user to this route
|
||||
*/
|
||||
redirectUserTo?: string
|
||||
/**
|
||||
* Redirect guest to this route
|
||||
*/
|
||||
redirectGuestTo?: string
|
||||
}
|
||||
|
||||
declare module '#app' {
|
||||
interface PageMeta {
|
||||
auth?: MiddlewareOptions
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
auth?: MiddlewareOptions
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
|
||||
// 1. If auth is disabled, skip middleware
|
||||
if (to.meta?.auth === false) {
|
||||
console.log('[1] Auth middleware disabled for this route:', to.path)
|
||||
// https://github.com/WaldemarEnns/nuxtui-github-auth/blob/7e3110f933d5d0445d3ac89d6c84c48052b49041/middleware/auth.global.ts
|
||||
const { loggedIn } = useUserSession()
|
||||
|
||||
if (to.meta.auth === false) {
|
||||
return
|
||||
}
|
||||
const { loggedIn, options, fetchSession, isPublicPath } = useAuth()
|
||||
const { only, redirectUserTo, redirectGuestTo } = defu(to.meta?.auth, options)
|
||||
|
||||
// 2. If guest mode, redirect if authenticated
|
||||
if (only === 'guest' && loggedIn.value) {
|
||||
console.log('[2] Guest mode: user is authenticated, redirecting to', redirectUserTo)
|
||||
if (to.path === redirectUserTo) {
|
||||
console.log('[2.1] Already at redirectUserTo:', redirectUserTo)
|
||||
return
|
||||
}
|
||||
return navigateTo(redirectUserTo)
|
||||
}
|
||||
|
||||
// 3. If client-side, fetch session between each navigation
|
||||
if (import.meta.client) {
|
||||
console.log('[3] Client-side navigation, fetching session')
|
||||
try {
|
||||
await fetchSession(to.path)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If not authenticated, redirect to home or guest route
|
||||
if (!loggedIn.value) {
|
||||
if (isPublicPath(to.path)) {
|
||||
console.log('[4] Not authenticated, but route is public:', to.path)
|
||||
// Continue navigating to the public route
|
||||
return
|
||||
}
|
||||
// No public route, redirect to guest route
|
||||
console.log('[4.1] Not authenticated, redirecting to guest route:', redirectGuestTo)
|
||||
return navigateTo(redirectGuestTo)
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
|
||||
83
legalconsenthub/middleware/refreshToken.global.ts
Normal file
83
legalconsenthub/middleware/refreshToken.global.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copied from https://github.com/atinux/nuxt-auth-utils/issues/91#issuecomment-2476019136
|
||||
|
||||
import { appendResponseHeader } from 'h3'
|
||||
import { parse, parseSetCookie, serialize } from 'cookie-es'
|
||||
import { jwtDecode, type JwtPayload } from 'jwt-decode'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const nuxtApp = useNuxtApp()
|
||||
// Don't run on client hydration when server rendered
|
||||
if (import.meta.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) return
|
||||
|
||||
console.log('🔍 Middleware: refreshToken.global.ts')
|
||||
console.log(` from: ${from.fullPath} to: ${to.fullPath}`)
|
||||
|
||||
const { session, clear: clearSession, fetch: fetchSession } = useUserSession()
|
||||
// Ignore if no tokens
|
||||
if (!session.value?.jwt) return
|
||||
|
||||
const serverEvent = useRequestEvent()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const { accessToken, refreshToken } = session.value.jwt
|
||||
|
||||
const accessPayload = jwtDecode(accessToken)
|
||||
const refreshPayload = jwtDecode(refreshToken)
|
||||
|
||||
// Both tokens expired, clearing session
|
||||
if (isExpired(accessPayload) && isExpired(refreshPayload)) {
|
||||
console.info('both tokens expired, clearing session')
|
||||
await clearSession()
|
||||
return navigateTo('/login')
|
||||
} else if (isExpired(accessPayload)) {
|
||||
console.info('access token expired, refreshing')
|
||||
await useRequestFetch()('/api/jwt/refresh', {
|
||||
method: 'POST',
|
||||
onResponse({ response: { headers } }) {
|
||||
// Forward the Set-Cookie header to the main server event
|
||||
if (import.meta.server && serverEvent) {
|
||||
for (const setCookie of headers.getSetCookie()) {
|
||||
appendResponseHeader(serverEvent, 'Set-Cookie', setCookie)
|
||||
// Update session cookie for next fetch requests
|
||||
const { name, value } = parseSetCookie(setCookie)
|
||||
|
||||
if (name === runtimeConfig.session.name) {
|
||||
console.log('updating headers.cookie to', value)
|
||||
const cookies = parse(serverEvent.headers.get('cookie') || '')
|
||||
|
||||
// set or overwrite existing cookie
|
||||
cookies[name] = value
|
||||
|
||||
// update cookie event header for future requests
|
||||
serverEvent.headers.set(
|
||||
'cookie',
|
||||
Object.entries(cookies)
|
||||
.map(([name, value]) => serialize(name, value))
|
||||
.join('; ')
|
||||
)
|
||||
|
||||
// Also apply to serverEvent.node.req.headers
|
||||
if (serverEvent.node?.req?.headers) {
|
||||
serverEvent.node.req.headers['cookie'] = serverEvent.headers.get('cookie') || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError() {
|
||||
console.error('🔍 Middleware: Token refresh failed')
|
||||
const { loggedIn } = useUserSession()
|
||||
if (!loggedIn.value) {
|
||||
console.log('🔍 Middleware: User not logged in, redirecting to /login')
|
||||
return navigateTo('/login')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Refresh the session
|
||||
await fetchSession()
|
||||
}
|
||||
})
|
||||
|
||||
function isExpired(payload: JwtPayload) {
|
||||
return payload?.exp && payload.exp < Date.now() / 1000
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
export default defineNuxtConfig({
|
||||
sourcemap: true,
|
||||
modules: [
|
||||
'@nuxt/ui-pro',
|
||||
'@nuxt/eslint',
|
||||
'@pinia/nuxt',
|
||||
'@nuxtjs/i18n'
|
||||
],
|
||||
modules: ['@nuxt/ui-pro', '@nuxt/eslint', '@pinia/nuxt', '@nuxtjs/i18n', 'nuxt-auth-utils'],
|
||||
css: ['~/assets/css/main.css'],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
clientProxyBasePath: 'NOT_SET',
|
||||
serverApiBaseUrl: 'NOT_SET',
|
||||
serverApiBasePath: 'NOT_SET',
|
||||
auth: {
|
||||
redirectUserTo: '/',
|
||||
redirectGuestTo: '/login'
|
||||
serverApiBasePath: 'NOT_SET'
|
||||
},
|
||||
oauth: {
|
||||
keycloak: {
|
||||
clientId: 'legalconsenthub',
|
||||
clientSecret: 'mROUAVlg3c0hepNt182FJgg6dEYsomc7',
|
||||
realm: 'legalconsenthub',
|
||||
serverUrl: 'http://localhost:7080',
|
||||
redirectURL: 'http://localhost:3001/auth/keycloak',
|
||||
scope: ['openid', 'organization']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,26 +7,23 @@
|
||||
"dev": "nuxt dev --port 3001 --host",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare && pnpm run fix:bettersqlite && pnpm run api:generate && pnpm run api:middleware:generate",
|
||||
"postinstall": "nuxt prepare && pnpm run api:generate",
|
||||
"format": "prettier . --write",
|
||||
"type-check": "nuxi typecheck",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"api:generate": "openapi-generator-cli generate -i ../legalconsenthub-backend/api/legalconsenthub.yml -g typescript-fetch -o .api-client",
|
||||
"api:middleware:generate": "openapi-generator-cli generate -i ../legalconsenthub-middleware/api/legalconsenthub-middleware.yml -g typescript-fetch -o .api-client-middleware",
|
||||
"fix:bettersqlite": "cd node_modules/better-sqlite3 && pnpm dlx node-gyp rebuild && cd ../..",
|
||||
"generate:betterauth": "pnpm dlx @better-auth/cli generate --config server/utils/auth.ts --yes",
|
||||
"migrate:betterauth": "pnpm dlx @better-auth/cli migrate --config server/utils/auth.ts --yes",
|
||||
"recreate-db:betterauth": "[ -f sqlite.db ] && rm sqlite.db; pnpm run migrate:betterauth"
|
||||
"api:middleware:generate": "openapi-generator-cli generate -i ../legalconsenthub-middleware/api/legalconsenthub-middleware.yml -g typescript-fetch -o .api-client-middleware"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui-pro": "3.1.1",
|
||||
"@nuxtjs/i18n": "10.0.3",
|
||||
"@pinia/nuxt": "0.10.1",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"better-auth": "1.3.9",
|
||||
"better-sqlite3": "11.8.1",
|
||||
"h3": "1.15.4",
|
||||
"jwt-decode": "4.0.0",
|
||||
"nuxt": "3.16.1",
|
||||
"nuxt-auth-utils": "0.5.25",
|
||||
"pinia": "3.0.1",
|
||||
"resend": "^4.3.0",
|
||||
"vue": "latest",
|
||||
@@ -35,7 +32,6 @@
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "1.1.0",
|
||||
"@openapitools/openapi-generator-cli": "2.16.3",
|
||||
"@types/better-sqlite3": "7.6.12",
|
||||
"eslint": "9.20.1",
|
||||
"prettier": "3.5.1",
|
||||
"typescript": "5.7.3",
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<UDashboardPanel>
|
||||
<template #header>
|
||||
<UDashboardNavbar title="Accept Invitation" :ui="{ right: 'gap-3' }">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
|
||||
<template #right />
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardToolbar>
|
||||
<template #left />
|
||||
</UDashboardToolbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-2xl mx-auto">
|
||||
<UPageCard title="Accept Invitation" description="Accept or decline the invitation." class="mb-6">
|
||||
<div v-if="invitation && !error">
|
||||
<div v-if="invitationStatus === 'pending'" class="space-y-4">
|
||||
<p>
|
||||
<strong>{{ invitation.inviterEmail }}</strong> has invited you to join
|
||||
<strong>{{ invitation.organizationName }}</strong
|
||||
>.
|
||||
</p>
|
||||
<p>
|
||||
This invitation was sent to <strong>{{ invitation.email }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="invitationStatus === 'accepted'" class="space-y-4 text-center">
|
||||
<div class="flex items-center justify-center w-16 h-16 mx-auto bg-green-100 rounded-full">
|
||||
<UIcon name="i-lucide-check" class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold">Welcome to {{ invitation.organizationName }}!</h2>
|
||||
<p>You've successfully joined the organization. We're excited to have you on board!</p>
|
||||
</div>
|
||||
|
||||
<div v-if="invitationStatus === 'rejected'" class="space-y-4 text-center">
|
||||
<div class="flex items-center justify-center w-16 h-16 mx-auto bg-red-100 rounded-full">
|
||||
<UIcon name="i-lucide-x" class="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold">Invitation Declined</h2>
|
||||
<p>You've declined the invitation to join {{ invitation.organizationName }}.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="invitationStatus === 'pending'" class="flex justify-between mt-6">
|
||||
<UButton variant="soft" color="neutral" @click="handleReject">Decline</UButton>
|
||||
<UButton color="primary" @click="handleAccept">Accept Invitation</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!invitation && !error" class="space-y-4">
|
||||
<USkeleton class="w-1/3 h-6" />
|
||||
<USkeleton class="w-full h-4" />
|
||||
<USkeleton class="w-2/3 h-4" />
|
||||
<USkeleton class="w-24 h-10 ml-auto" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="text-red-600 font-medium">{{ error }}</p>
|
||||
</div>
|
||||
</UPageCard>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CustomInvitation } from '~/types/auth'
|
||||
|
||||
const invitationId = useRoute().params.id as string
|
||||
|
||||
const { acceptInvitation, rejectInvitation, getInvitation } = useOrganizationStore()
|
||||
const invitation = ref<CustomInvitation>(null)
|
||||
const invitationStatus = ref<'pending' | 'accepted' | 'rejected'>('pending')
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function handleAccept() {
|
||||
await acceptInvitation(invitationId)
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
await rejectInvitation(invitationId)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
invitation.value = await getInvitation(invitationId)
|
||||
})
|
||||
</script>
|
||||
@@ -1,191 +0,0 @@
|
||||
<template>
|
||||
<UDashboardPanel>
|
||||
<template #header>
|
||||
<UDashboardNavbar title="Administration" :ui="{ right: 'gap-3' }">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
|
||||
<template #right />
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardToolbar>
|
||||
<template #left />
|
||||
</UDashboardToolbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-2xl mx-auto">
|
||||
<UPageCard
|
||||
title="Organization Selection"
|
||||
description="Choose an organization or create a new one."
|
||||
class="mb-6"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<USelect
|
||||
v-model="selectedOrganizationId"
|
||||
:items="labeledOrganizations"
|
||||
value-key="value"
|
||||
placeholder="Select organization"
|
||||
class="w-64"
|
||||
/>
|
||||
<CreateOrganizationModal />
|
||||
</div>
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard
|
||||
title="Organization Overview"
|
||||
description="View your current organization and its details."
|
||||
class="mb-6"
|
||||
>
|
||||
<div class="flex gap-4 items-center">
|
||||
<UAvatar :src="activeOrganization?.logo || undefined" size="md" alt="Org Logo" class="rounded" />
|
||||
<div>
|
||||
<p class="font-semibold">{{ activeOrganization?.name }}</p>
|
||||
<p class="text-xs text-gray-500">{{ activeOrganization?.members?.length || 1 }} members</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<UButton color="error" icon="i-lucide-trash" @click="deleteOrganization"> Delete Organization </UButton>
|
||||
</div>
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard title="Members & Invitations" description="Manage team members and pending invites.">
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<!-- Members -->
|
||||
<div class="flex-1">
|
||||
<p class="font-medium mb-2">Members</p>
|
||||
<div v-if="activeOrganizationMembers" class="space-y-2">
|
||||
<div
|
||||
v-for="member in activeOrganizationMembers"
|
||||
:key="member.id"
|
||||
class="flex justify-between items-center"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UAvatar :src="member.user.image || undefined" size="sm" />
|
||||
<div>
|
||||
<p class="text-sm">{{ member.user.name }}</p>
|
||||
<p class="text-xs text-gray-500">{{ member.role }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="user && canRemove({ role: 'owner' }, member)">
|
||||
<UButton size="xs" color="error" @click="organizationStore.removeMember(member.id)">
|
||||
{{ member.user.id === user.id ? 'Leave' : 'Remove' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!activeOrganization?.id" class="flex items-center gap-2">
|
||||
<UAvatar :src="user?.image ?? undefined" />
|
||||
<div>
|
||||
<p class="text-sm">{{ user?.name }}</p>
|
||||
<p class="text-xs text-gray-500">Owner</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invitations -->
|
||||
<div class="flex-1">
|
||||
<p class="font-medium mb-2">Invites</p>
|
||||
<div class="space-y-2">
|
||||
<template v-if="invitations.length > 0">
|
||||
<div
|
||||
v-for="invitation in invitations.filter((i: Invitation) => i.status === 'pending')"
|
||||
:key="invitation.id"
|
||||
class="flex justify-between items-center"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm">{{ invitation.email }}</p>
|
||||
<p class="text-xs text-gray-500">{{ invitation.role }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
size="xs"
|
||||
color="error"
|
||||
:loading="isRevoking.includes(invitation.id)"
|
||||
@click="() => handleInvitationCancellation(invitation.id)"
|
||||
>
|
||||
Revoke
|
||||
</UButton>
|
||||
<UButton icon="i-lucide-copy" size="xs" @click="copy(getInviteLink(invitation.id))">
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-sm text-gray-500">No active invitations</p>
|
||||
<p v-if="!activeOrganization?.id" class="text-xs text-gray-500">
|
||||
You can't invite members to your personal workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-6">
|
||||
<InviteMemberModal
|
||||
v-if="activeOrganization?.id"
|
||||
:organization="activeOrganization"
|
||||
@update="activeOrganization = $event"
|
||||
/>
|
||||
</div>
|
||||
</UPageCard>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import type { Invitation } from 'better-auth/plugins'
|
||||
|
||||
const { copy, copied } = useClipboard()
|
||||
const { user } = useAuth()
|
||||
const organizationStore = useOrganizationStore()
|
||||
const { activeOrganization, activeOrganizationMembers, organizations, invitations } = storeToRefs(organizationStore)
|
||||
|
||||
const isRevoking = ref<string[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
await organizationStore.loadOrganizations()
|
||||
})
|
||||
|
||||
const labeledOrganizations = computed(() => organizations.value.map((org) => ({ label: org.name, value: org.id })))
|
||||
const selectedOrganizationId = computed({
|
||||
get() {
|
||||
return activeOrganization.value?.id
|
||||
},
|
||||
set(id: string) {
|
||||
organizationStore.setActiveOrganization(id)
|
||||
}
|
||||
})
|
||||
|
||||
function isAdminOrOwner(member: { role: string }) {
|
||||
return member.role === 'owner' || member.role === 'admin'
|
||||
}
|
||||
|
||||
function canRemove(current: { role: string }, target: { role: string }) {
|
||||
return target.role !== 'owner' && isAdminOrOwner(current)
|
||||
}
|
||||
|
||||
async function handleInvitationCancellation(invitationId: string) {
|
||||
isRevoking.value.push(invitationId)
|
||||
await organizationStore.cancelSentInvitation(invitationId)
|
||||
}
|
||||
|
||||
function getInviteLink(inviteId: string): string {
|
||||
return `${window.location.origin}/accept-invitation/${inviteId}`
|
||||
}
|
||||
|
||||
async function deleteOrganization() {
|
||||
if (!activeOrganization.value?.id) return
|
||||
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to delete the organization "${activeOrganization.value.name}"? This cannot be undone.`
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
await organizationStore.deleteOrganization()
|
||||
}
|
||||
</script>
|
||||
@@ -80,7 +80,9 @@ import type { StepperItem } from '@nuxt/ui'
|
||||
|
||||
const { getApplicationFormById, updateApplicationForm, submitApplicationForm } = useApplicationForm()
|
||||
const route = useRoute()
|
||||
const { user } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
const { user } = storeToRefs(userStore)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
definePageMeta({
|
||||
|
||||
19
legalconsenthub/pages/callback.vue
Normal file
19
legalconsenthub/pages/callback.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<h1>Authentication callback processing...</h1>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useKeycloak } from '~/composables/useKeycloak'
|
||||
|
||||
const { userManager } = useKeycloak()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const user = await userManager.signinRedirectCallback()
|
||||
console.log('User logged in', user)
|
||||
await navigateTo('/')
|
||||
} catch (e) {
|
||||
console.error('Error during login', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-4xl mx-auto">
|
||||
<div v-if="!canCreateApplicationForm" class="text-center py-12">
|
||||
<div v-if="!true" class="text-center py-12">
|
||||
<UIcon name="i-lucide-shield-x" class="w-16 h-16 mx-auto text-red-400 mb-4" />
|
||||
<h2 class="text-2xl font-semibold text-gray-700 mb-2">Keine Berechtigung</h2>
|
||||
<p class="text-gray-500 mb-4">Sie haben keine Berechtigung zum Erstellen von Anträgen.</p>
|
||||
@@ -83,15 +83,19 @@ import { useApplicationFormValidator } from '~/composables/useApplicationFormVal
|
||||
import type { FormElementId } from '~/types/formElement'
|
||||
import type { StepperItem } from '@nuxt/ui'
|
||||
|
||||
const { getAllApplicationFormTemplates } = useApplicationFormTemplate()
|
||||
const { getAllApplicationFormTemplates } = await useApplicationFormTemplate()
|
||||
const { createApplicationForm, submitApplicationForm } = useApplicationForm()
|
||||
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
|
||||
const { selectedOrganization } = useAuth()
|
||||
const { canCreateApplicationForm, getCurrentRoleInfo } = usePermissions()
|
||||
const userStore = useUserStore()
|
||||
const { selectedOrganization } = storeToRefs(userStore)
|
||||
const toast = useToast()
|
||||
|
||||
// Get current role information for display
|
||||
const currentRoleInfo = computed(() => getCurrentRoleInfo())
|
||||
const currentRoleInfo = {
|
||||
name: 'Mitarbeiter',
|
||||
description: 'Sie können Anträge erstellen und bearbeiten.',
|
||||
color: 'info'
|
||||
}
|
||||
|
||||
const stepper = useTemplateRef('stepper')
|
||||
const activeStepperItemIndex = ref<number>(0)
|
||||
@@ -196,6 +200,7 @@ async function prepareAndCreateApplicationForm() {
|
||||
return null
|
||||
}
|
||||
|
||||
console.log('selectedOrganization', selectedOrganization.value)
|
||||
applicationFormTemplate.value.organizationId = selectedOrganization.value?.id ?? ''
|
||||
|
||||
return await createApplicationForm(applicationFormTemplate.value)
|
||||
|
||||
@@ -78,10 +78,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ApplicationFormDto, PagedApplicationFormDto } from '~/.api-client'
|
||||
import type { Organization } from '~/types/keycloak'
|
||||
|
||||
const { getAllApplicationForms, deleteApplicationFormById } = useApplicationForm()
|
||||
const route = useRoute()
|
||||
const { organizations, selectedOrganization } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
const { organizations, selectedOrganization } = storeToRefs(userStore)
|
||||
|
||||
// Inject notification state from layout
|
||||
const { isNotificationsSlideoverOpen, unreadCount } = inject('notificationState', {
|
||||
@@ -117,7 +119,7 @@ const selectedOrganizationId = computed({
|
||||
},
|
||||
set(item) {
|
||||
// TODO: USelect triggers multiple times after single selection
|
||||
selectedOrganization.value = organizations.value.find((i) => i.id === item) ?? null
|
||||
selectedOrganization.value = organizations.value.find((i: Organization) => i.id === item) ?? null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,80 +1,42 @@
|
||||
<template>
|
||||
<UAuthForm
|
||||
:fields="fields"
|
||||
:schema="signInSchema"
|
||||
:providers="providers"
|
||||
title="Welcome back"
|
||||
icon="i-lucide-lock"
|
||||
@submit="onLoginSubmit"
|
||||
>
|
||||
<template #description>
|
||||
Don't have an account? <ULink to="/signup" class="text-primary-500 font-medium">Sign up</ULink>.
|
||||
</template>
|
||||
<UCard variant="subtle">
|
||||
<template #header>
|
||||
<div class="text-center">
|
||||
<UIcon name="i-lucide-lock" class="mx-auto h-16 w-16 text-primary-500 mb-6" />
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">
|
||||
Welcome
|
||||
</h1>
|
||||
<p class="text-gray-600">
|
||||
You will be redirected to Keycloak to authenticate
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #password-hint>
|
||||
<ULink to="/" class="text-primary-500 font-medium">Forgot password?</ULink>
|
||||
</template>
|
||||
<div class="text-center">
|
||||
<UButton
|
||||
color="primary"
|
||||
size="xl"
|
||||
icon="i-lucide-log-in"
|
||||
@click="handleSignIn"
|
||||
>
|
||||
Sign in with Keycloak
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
By signing in, you agree to our <ULink to="/" class="text-primary-500 font-medium">Terms of Service</ULink>.
|
||||
</template>
|
||||
</UAuthForm>
|
||||
<template #footer>
|
||||
<div class="text-center text-xs text-gray-500">
|
||||
By signing in, you agree to our terms of service
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
import { signInSchema, type SignInSchema } from '~/types/schemas'
|
||||
|
||||
definePageMeta({ layout: 'auth' })
|
||||
definePageMeta({ auth: false, layout: 'auth' })
|
||||
|
||||
useSeoMeta({ title: 'Login' })
|
||||
|
||||
const toast = useToast()
|
||||
const { signIn } = useAuth()
|
||||
|
||||
const fields = [
|
||||
{
|
||||
name: 'email',
|
||||
type: 'text' as const,
|
||||
label: 'Email',
|
||||
placeholder: 'Enter your email',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password' as const,
|
||||
placeholder: 'Enter your password'
|
||||
},
|
||||
{
|
||||
name: 'remember',
|
||||
label: 'Remember me',
|
||||
type: 'checkbox' as const
|
||||
}
|
||||
]
|
||||
|
||||
const providers = [
|
||||
{
|
||||
label: 'Google',
|
||||
icon: 'i-simple-icons-google',
|
||||
onClick: () => {
|
||||
toast.add({ title: 'Google', description: 'Login with Google' })
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
icon: 'i-simple-icons-github',
|
||||
onClick: () => {
|
||||
toast.add({ title: 'GitHub', description: 'Login with GitHub' })
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
function onLoginSubmit(payload: FormSubmitEvent<SignInSchema>) {
|
||||
if (!payload.data.email || !payload.data.password) {
|
||||
alert('Bitte alle Felder ausfüllen')
|
||||
return
|
||||
}
|
||||
signIn(payload)
|
||||
function handleSignIn() {
|
||||
navigateTo('/auth/keycloak', { external: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<UAuthForm
|
||||
:fields="fields"
|
||||
:schema="signUpSchema"
|
||||
:providers="providers"
|
||||
title="Create an account"
|
||||
:submit="{ label: 'Create account' }"
|
||||
@submit="onSignUpSubmit"
|
||||
>
|
||||
<template #description>
|
||||
Already have an account? <ULink to="/login" class="text-primary-500 font-medium">Login</ULink>.
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
By signing up, you agree to our <ULink to="/" class="text-primary-500 font-medium">Terms of Service</ULink>.
|
||||
</template>
|
||||
</UAuthForm>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
import { signUpSchema, type SignUpSchema } from '~/types/schemas'
|
||||
|
||||
definePageMeta({ layout: 'auth' })
|
||||
|
||||
useSeoMeta({ title: 'Sign up' })
|
||||
|
||||
const toast = useToast()
|
||||
const { signUp } = useAuth()
|
||||
|
||||
const fields = [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text' as const,
|
||||
label: 'Name',
|
||||
placeholder: 'Enter your name'
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'text' as const,
|
||||
label: 'Email',
|
||||
placeholder: 'Enter your email'
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password' as const,
|
||||
placeholder: 'Enter your password'
|
||||
}
|
||||
]
|
||||
|
||||
const providers = [
|
||||
{
|
||||
label: 'Google',
|
||||
icon: 'i-simple-icons-google',
|
||||
onClick: () => {
|
||||
toast.add({ title: 'Google', description: 'Login with Google' })
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
icon: 'i-simple-icons-github',
|
||||
onClick: () => {
|
||||
toast.add({ title: 'GitHub', description: 'Login with GitHub' })
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
function onSignUpSubmit(payload: FormSubmitEvent<SignUpSchema>) {
|
||||
signUp(payload)
|
||||
}
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
// Copied from https://github.com/atinux/nuxthub-better-auth
|
||||
|
||||
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
if (!nuxtApp.payload.serverRendered) {
|
||||
await useAuth().fetchSession()
|
||||
} else if (Boolean(nuxtApp.payload.prerenderedAt) || Boolean(nuxtApp.payload.isCached)) {
|
||||
// To avoid hydration mismatch
|
||||
nuxtApp.hook('app:mounted', async () => {
|
||||
await useAuth().fetchSession()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,13 +0,0 @@
|
||||
// Copied from https://github.com/atinux/nuxthub-better-auth
|
||||
|
||||
export default defineNuxtPlugin({
|
||||
name: 'better-auth-fetch-plugin',
|
||||
enforce: 'pre',
|
||||
async setup(nuxtApp) {
|
||||
// Flag if request is cached
|
||||
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache)
|
||||
if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) {
|
||||
await useAuth().fetchSession()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -3,11 +3,11 @@ export default defineNuxtPlugin(() => {
|
||||
if (import.meta.client) {
|
||||
// Initialize server health monitoring as soon as the client is ready
|
||||
const { startPeriodicHealthCheck } = useServerHealth()
|
||||
|
||||
|
||||
// Start the health check with a 1-minute interval
|
||||
// This ensures the health check starts even if app.vue's onMounted hasn't fired yet
|
||||
nextTick(() => {
|
||||
startPeriodicHealthCheck(60000)
|
||||
})
|
||||
// nextTick(() => {
|
||||
// startPeriodicHealthCheck(60000)
|
||||
// })
|
||||
}
|
||||
})
|
||||
|
||||
742
legalconsenthub/pnpm-lock.yaml
generated
742
legalconsenthub/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,32 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { joinURL } from 'ufo'
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
|
||||
export default defineEventHandler((event: H3Event) => {
|
||||
export default defineEventHandler(async (event: H3Event) => {
|
||||
const { serverApiBaseUrl, clientProxyBasePath } = useRuntimeConfig().public
|
||||
const escapedClientProxyBasePath = clientProxyBasePath.replace(/^\//, '\\/')
|
||||
// Use the escaped value in the regex
|
||||
const path = event.path.replace(new RegExp(`^${escapedClientProxyBasePath}`), '')
|
||||
const target = joinURL(serverApiBaseUrl, path)
|
||||
|
||||
const session = await getUserSession(event)
|
||||
const accessToken = session?.jwt?.accessToken
|
||||
|
||||
console.log('🔍 PROXY: proxying request, found access token:', accessToken)
|
||||
console.log('🔍 PROXY: Expiration:', new Date(jwtDecode(accessToken).exp! * 1000).toISOString())
|
||||
|
||||
if (!accessToken) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Not authenticated'
|
||||
})
|
||||
}
|
||||
|
||||
console.log('🔀 proxying request to', target)
|
||||
|
||||
return proxyRequest(event, target)
|
||||
return proxyRequest(event, target, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { auth } from '../../utils/auth'
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
export default defineEventHandler((event: H3Event) => {
|
||||
return auth.handler(toWebRequest(event))
|
||||
})
|
||||
46
legalconsenthub/server/api/jwt/refresh.post.ts
Normal file
46
legalconsenthub/server/api/jwt/refresh.post.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { OAuthTokenResponse } from '~/types/oauth'
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const session = await getUserSession(event)
|
||||
if (!session.jwt?.accessToken && !session.jwt?.refreshToken) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'Unauthorized'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { access_token, refresh_token } = await $fetch<OAuthTokenResponse>(
|
||||
`http://localhost:7080/realms/legalconsenthub/protocol/openid-connect/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.oauth.keycloak.clientId,
|
||||
client_secret: config.oauth.keycloak.clientSecret,
|
||||
refresh_token: session.jwt.refreshToken
|
||||
}).toString()
|
||||
}
|
||||
)
|
||||
|
||||
await setUserSession(event, {
|
||||
jwt: {
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token || session.jwt.refreshToken
|
||||
},
|
||||
loggedInAt: Date.now()
|
||||
})
|
||||
|
||||
return {
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token || session.jwt.refreshToken
|
||||
}
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'refresh token is invalid'
|
||||
})
|
||||
}
|
||||
})
|
||||
56
legalconsenthub/server/routes/auth/keycloak.get.ts
Normal file
56
legalconsenthub/server/routes/auth/keycloak.get.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
import type { KeycloakTokenPayload, Organization } from '~/types/keycloak'
|
||||
|
||||
export default defineOAuthKeycloakEventHandler({
|
||||
async onSuccess(event, { user, tokens }) {
|
||||
const rawAccessToken = tokens?.access_token
|
||||
let decodedJwt: KeycloakTokenPayload | null = null
|
||||
|
||||
try {
|
||||
decodedJwt = jwtDecode<KeycloakTokenPayload>(rawAccessToken!)
|
||||
} catch (err) {
|
||||
console.warn('[auth] Failed to decode access token:', err)
|
||||
}
|
||||
|
||||
const organizations = decodedJwt ? extractOrganizations(decodedJwt) : []
|
||||
|
||||
await setUserSession(event, {
|
||||
user: {
|
||||
keycloakId: user.sub,
|
||||
name: user.preferred_username,
|
||||
organizations
|
||||
},
|
||||
jwt: {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in
|
||||
},
|
||||
loggedInAt: Date.now()
|
||||
})
|
||||
|
||||
return sendRedirect(event, '/')
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
console.log('error during keycloak authentication', event)
|
||||
return sendRedirect(event, '/login')
|
||||
}
|
||||
})
|
||||
|
||||
function extractOrganizations(decoded: KeycloakTokenPayload): Organization[] {
|
||||
const organizations: Organization[] = []
|
||||
const orgClaim = decoded?.organization ?? null
|
||||
|
||||
if (orgClaim && typeof orgClaim === 'object') {
|
||||
Object.entries(orgClaim).forEach(([name, meta]) => {
|
||||
if (!name || !meta?.id) return
|
||||
|
||||
organizations.push({
|
||||
name: name,
|
||||
id: meta.id
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return organizations
|
||||
}
|
||||
12
legalconsenthub/server/routes/auth/logout.get.ts
Normal file
12
legalconsenthub/server/routes/auth/logout.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const cleared = await clearUserSession(event)
|
||||
if (!cleared) {
|
||||
console.warn('Failed to clear user session')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing user session:', error)
|
||||
}
|
||||
|
||||
return sendRedirect(event, '/login', 200)
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import Database from 'better-sqlite3'
|
||||
import { organization, jwt } from 'better-auth/plugins'
|
||||
import { resend } from './mail'
|
||||
import {
|
||||
accessControl,
|
||||
employerRole,
|
||||
worksCouncilMemberRole,
|
||||
employeeRole,
|
||||
adminRole,
|
||||
ownerRole,
|
||||
ROLES,
|
||||
type LegalRole
|
||||
} from './permissions'
|
||||
|
||||
const db = new Database('./sqlite.db')
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: db,
|
||||
onAPIError: { throw: true },
|
||||
emailAndPassword: { enabled: true, autoSignIn: false, minPasswordLength: 1 },
|
||||
trustedOrigins: ['http://localhost:3001'],
|
||||
plugins: [
|
||||
jwt({
|
||||
jwt: {
|
||||
issuer: 'http://192.168.178.114:3001',
|
||||
expirationTime: '1yr',
|
||||
definePayload: ({ user, session }) => {
|
||||
let userRoles: string[] = []
|
||||
|
||||
if (session.activeOrganizationId) {
|
||||
try {
|
||||
const roleQuery = db.prepare(`
|
||||
SELECT role
|
||||
FROM member
|
||||
WHERE userId = ? AND organizationId = ?
|
||||
`)
|
||||
const memberRole = roleQuery.get(user.id, session.activeOrganizationId) as { role: string } | undefined
|
||||
|
||||
if (memberRole?.role) {
|
||||
userRoles = [memberRole.role]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error querying user role:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
roles: userRoles,
|
||||
organizationId: session.activeOrganizationId
|
||||
}
|
||||
}
|
||||
},
|
||||
jwks: {
|
||||
keyPairConfig: {
|
||||
// Supported by NimbusJwtDecoder
|
||||
alg: 'ES512'
|
||||
}
|
||||
}
|
||||
}),
|
||||
organization({
|
||||
// Pass the access control instance and roles
|
||||
ac: accessControl,
|
||||
roles: {
|
||||
[ROLES.EMPLOYER]: employerRole,
|
||||
[ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole,
|
||||
[ROLES.EMPLOYEE]: employeeRole,
|
||||
[ROLES.ADMIN]: adminRole,
|
||||
[ROLES.OWNER]: ownerRole
|
||||
},
|
||||
creatorRole: ROLES.ADMIN, // OWNER fixen here!
|
||||
|
||||
async sendInvitationEmail(data) {
|
||||
console.log('Sending invitation email', data)
|
||||
const inviteLink = `http://192.168.178.114:3001/accept-invitation/${data.id}`
|
||||
|
||||
const roleDisplayNames = {
|
||||
[ROLES.EMPLOYER]: 'Arbeitgeber',
|
||||
[ROLES.EMPLOYEE]: 'Arbeitnehmer',
|
||||
[ROLES.WORKS_COUNCIL_MEMBER]: 'Betriebsrat',
|
||||
[ROLES.ADMIN]: 'Administrator',
|
||||
[ROLES.OWNER]: 'Eigentümer'
|
||||
}
|
||||
|
||||
const roleDisplayName = roleDisplayNames[data.role as LegalRole] || data.role
|
||||
|
||||
try {
|
||||
const result = await resend.emails.send({
|
||||
from: 'Acme <onboarding@resend.dev>',
|
||||
to: data.email,
|
||||
subject: `Einladung als ${roleDisplayName} - ${data.organization.name}`,
|
||||
html: `
|
||||
<h2>Einladung zur Organisation ${data.organization.name}</h2>
|
||||
<p>Sie wurden als <strong>${roleDisplayName}</strong> eingeladen.</p>
|
||||
<p><a href="${inviteLink}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Einladung annehmen</a></p>
|
||||
<p>Diese Einladung läuft ab am: ${new Date(data.invitation.expiresAt).toLocaleDateString('de-DE')}</p>
|
||||
`
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Email sending failed: ${result.error.message || result.error.name || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
console.log('Email invite link:', inviteLink)
|
||||
console.log('Invitation email sent successfully to:', data.email, 'with ID:', result.data?.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to send invitation email:', error)
|
||||
|
||||
// Log specific error details for debugging
|
||||
const errorObj = error as { response?: { status: number; data: unknown }; message?: string }
|
||||
if (errorObj.response) {
|
||||
console.error('HTTP Status:', errorObj.response.status)
|
||||
console.error('Response data:', errorObj.response.data)
|
||||
}
|
||||
|
||||
// Re-throw the error so BetterAuth knows the email failed
|
||||
const message = errorObj.message || String(error)
|
||||
throw new Error(`Email sending failed: ${message}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
export { ROLES }
|
||||
export type { LegalRole }
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Resend } from 'resend'
|
||||
|
||||
export const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
@@ -1,87 +0,0 @@
|
||||
import { createAccessControl } from 'better-auth/plugins/access'
|
||||
import { defaultStatements, adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
|
||||
import { defu } from 'defu'
|
||||
|
||||
const customStatements = {
|
||||
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject', 'submit'],
|
||||
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
|
||||
comment: ['create', 'read', 'update', 'delete'],
|
||||
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
|
||||
} as const
|
||||
|
||||
export const statement = {
|
||||
...customStatements,
|
||||
...defaultStatements
|
||||
} as const
|
||||
|
||||
export const accessControl = createAccessControl(statement)
|
||||
|
||||
export const employerRole = accessControl.newRole(
|
||||
defu(
|
||||
{
|
||||
application_form: ['create', 'read', 'approve', 'reject'],
|
||||
agreement: ['create', 'read', 'sign', 'approve'],
|
||||
comment: ['create', 'read', 'update', 'delete'],
|
||||
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
|
||||
},
|
||||
memberAc.statements
|
||||
) as Parameters<typeof accessControl.newRole>[0]
|
||||
)
|
||||
|
||||
export const worksCouncilMemberRole = accessControl.newRole(
|
||||
defu(
|
||||
{
|
||||
application_form: ['create', 'read', 'update', 'submit'],
|
||||
agreement: ['read', 'sign', 'approve'],
|
||||
comment: ['create', 'read', 'update', 'delete'],
|
||||
document: ['create', 'read', 'update', 'download', 'upload']
|
||||
},
|
||||
memberAc.statements
|
||||
) as Parameters<typeof accessControl.newRole>[0]
|
||||
)
|
||||
|
||||
export const employeeRole = accessControl.newRole(
|
||||
defu(
|
||||
{
|
||||
application_form: ['read'],
|
||||
agreement: ['read'],
|
||||
comment: ['create', 'read'],
|
||||
document: ['read', 'download']
|
||||
},
|
||||
memberAc.statements
|
||||
) as Parameters<typeof accessControl.newRole>[0]
|
||||
)
|
||||
|
||||
export const adminRole = accessControl.newRole(
|
||||
defu(
|
||||
{
|
||||
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject'],
|
||||
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
|
||||
comment: ['create', 'read', 'update', 'delete'],
|
||||
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
|
||||
},
|
||||
adminAc.statements
|
||||
) as Parameters<typeof accessControl.newRole>[0]
|
||||
)
|
||||
|
||||
export const ownerRole = accessControl.newRole(
|
||||
defu(
|
||||
{
|
||||
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject', 'submit'],
|
||||
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
|
||||
comment: ['create', 'read', 'update', 'delete'],
|
||||
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
|
||||
},
|
||||
ownerAc.statements
|
||||
) as Parameters<typeof accessControl.newRole>[0]
|
||||
)
|
||||
|
||||
export const ROLES = {
|
||||
EMPLOYER: 'employer',
|
||||
WORKS_COUNCIL_MEMBER: 'works_council_member',
|
||||
EMPLOYEE: 'employee',
|
||||
ADMIN: 'admin',
|
||||
OWNER: 'owner'
|
||||
} as const
|
||||
|
||||
export type LegalRole = (typeof ROLES)[keyof typeof ROLES]
|
||||
Binary file not shown.
@@ -1,256 +0,0 @@
|
||||
import { useOrganizationApi } from '~/composables/organization/useOrganizationApi'
|
||||
import type { LegalRole } from '~/server/utils/permissions'
|
||||
import type {
|
||||
ActiveOrganization,
|
||||
CustomInvitation,
|
||||
Invitation,
|
||||
ListMembersOptions,
|
||||
ListMembersQuery,
|
||||
ListMembersResponse,
|
||||
Member,
|
||||
Organization
|
||||
} from '~/types/auth'
|
||||
|
||||
export const useOrganizationStore = defineStore('Organization', () => {
|
||||
const activeOrganization = ref<ActiveOrganization | null>(null)
|
||||
const organizations = ref<Organization[]>([])
|
||||
const invitations = ref<Invitation[]>([])
|
||||
const activeOrganizationMembers = ref<ListMembersResponse>([])
|
||||
|
||||
const organizationApi = useOrganizationApi()
|
||||
const toast = useToast()
|
||||
|
||||
async function createOrganization(name: string, slug: string, logo?: string) {
|
||||
const { data: slugCheck, error: slugError } = await organizationApi.checkSlugAvailability(slug)
|
||||
if (slugError) {
|
||||
toast.add({
|
||||
title: 'Error checking slug availability',
|
||||
description: slugError.message,
|
||||
color: 'error'
|
||||
})
|
||||
console.error('Error checking slug availability:', slugError)
|
||||
return Promise.reject(slugError)
|
||||
}
|
||||
|
||||
if (!slugCheck?.status) {
|
||||
toast.add({
|
||||
title: 'Slug already taken',
|
||||
description: 'Please choose a different slug',
|
||||
color: 'error'
|
||||
})
|
||||
return Promise.reject()
|
||||
}
|
||||
|
||||
const { data: createdOrganization, error } = await organizationApi.createOrganization(name, slug, logo)
|
||||
if (error) {
|
||||
toast.add({ title: 'Error creating organization', color: 'error' })
|
||||
console.error('Error creating organization:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (createdOrganization) {
|
||||
organizations.value.push(createdOrganization)
|
||||
toast.add({ title: 'Organization created successfully', color: 'success' })
|
||||
|
||||
if (createdOrganization.id) {
|
||||
await setActiveOrganization(createdOrganization.id)
|
||||
}
|
||||
|
||||
return createdOrganization
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOrganization(organizationId?: string) {
|
||||
const idToDelete = organizationId ?? activeOrganization.value?.id
|
||||
if (!idToDelete) {
|
||||
const error = new Error('No organization is selected for deletion')
|
||||
toast.add({ title: 'Error deleting organization', color: 'error' })
|
||||
console.error('Error deleting organization:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const { error } = await organizationApi.deleteOrganization(idToDelete)
|
||||
if (error) {
|
||||
toast.add({ title: 'Error deleting organization', color: 'error' })
|
||||
console.error('Error deleting organization:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
organizations.value = organizations.value.filter((org) => org.id !== organizationId)
|
||||
toast.add({ title: 'Organization deleted successfully', color: 'success' })
|
||||
}
|
||||
|
||||
async function getInvitation(invitationId: string): Promise<CustomInvitation> {
|
||||
const { data: invitation, error } = await organizationApi.getInvitation(invitationId)
|
||||
if (error) {
|
||||
toast.add({ title: error.message, color: 'error' })
|
||||
console.error('Error getting invitation:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
return invitation
|
||||
}
|
||||
|
||||
async function loadInvitations(organizationId?: string) {
|
||||
const { data: loadedInvitations, error } = await organizationApi.listInvitations(organizationId)
|
||||
if (error) {
|
||||
toast.add({ title: 'Error loading invitations', color: 'error' })
|
||||
console.error('Error loading invitations:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (loadedInvitations) {
|
||||
invitations.value = loadedInvitations
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteMember(email: string, role: LegalRole) {
|
||||
const { error } = await organizationApi.inviteMember(email, role)
|
||||
if (error) {
|
||||
toast.add({ title: 'Error inviting member', color: 'error' })
|
||||
console.error('Error inviting member:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
await loadInvitations()
|
||||
toast.add({ title: 'Member invited successfully', color: 'success' })
|
||||
}
|
||||
|
||||
async function removeMember(memberId: string) {
|
||||
const { error } = await organizationApi.removeMember(memberId)
|
||||
if (error) {
|
||||
toast.add({ title: 'Error removing member', color: 'error' })
|
||||
console.error('Error removing member:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
activeOrganizationMembers.value = activeOrganizationMembers.value.filter((member: Member) => member.id !== memberId)
|
||||
toast.add({ title: 'Member removed successfully', color: 'success' })
|
||||
}
|
||||
|
||||
async function acceptInvitation(invitationId: string) {
|
||||
const { error } = await organizationApi.acceptInvitation(invitationId)
|
||||
if (error) {
|
||||
toast.add({ title: 'Error accepting invitation', color: 'error' })
|
||||
console.error('Error accepting invitation:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
await navigateTo('/')
|
||||
await syncUserRoleToBackend()
|
||||
}
|
||||
|
||||
async function syncUserRoleToBackend() {
|
||||
const { updateUser } = useUser()
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user.value?.id) {
|
||||
const error = new Error('No user ID available for role sync')
|
||||
console.warn('No user ID available for role sync')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Call updateUser without userDto to trigger JWT-based sync
|
||||
const updatedUser = await updateUser(user.value.id)
|
||||
if (updatedUser) {
|
||||
console.log('Successfully synced user role to backend from JWT')
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSentInvitation(invitationId: string) {
|
||||
const { error } = await organizationApi.cancelSentInvitation(invitationId)
|
||||
if (error) {
|
||||
toast.add({ title: 'Error rejecting invitation', color: 'error' })
|
||||
console.error('Error rejecting invitation:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
invitations.value = invitations.value.filter((invitation) => invitation.id !== invitationId)
|
||||
}
|
||||
|
||||
async function rejectInvitation(invitationId: string) {
|
||||
const { error } = await organizationApi.rejectInvitation(invitationId)
|
||||
if (error) {
|
||||
toast.add({ title: 'Error rejecting invitation', color: 'error' })
|
||||
console.error('Error rejecting invitation:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
invitations.value = invitations.value.filter((invitation) => invitation.id !== invitationId)
|
||||
}
|
||||
|
||||
async function loadOrganizations() {
|
||||
const { data: loadedOrganizations, error } = await organizationApi.loadOrganizations()
|
||||
if (error) {
|
||||
toast.add({ title: 'Error loading organizations', color: 'error' })
|
||||
console.error('Error loading organizations:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (loadedOrganizations) {
|
||||
organizations.value = loadedOrganizations
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveOrganization(organizationId: string) {
|
||||
const { data: activeOrganizationToSet, error } = await organizationApi.setActiveOrganization(organizationId)
|
||||
if (error) {
|
||||
toast.add({ title: 'Error setting active organizations', color: 'error' })
|
||||
console.error('Error setting active organizations:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
activeOrganization.value = activeOrganizationToSet
|
||||
|
||||
const { data: invitationsToSet, error: invitationsError } = await organizationApi.listInvitations(
|
||||
activeOrganizationToSet?.id
|
||||
)
|
||||
if (invitationsError) {
|
||||
console.error('Error loading invitations for active organization:', invitationsError)
|
||||
} else {
|
||||
invitations.value = invitationsToSet ?? []
|
||||
}
|
||||
|
||||
await loadMembers()
|
||||
}
|
||||
|
||||
async function loadMembers(options?: Omit<NonNullable<ListMembersQuery>, 'organizationId'>) {
|
||||
if (!activeOrganization.value?.id) {
|
||||
const error = new Error('No active organization to load members for')
|
||||
console.error('Error getting members: No active organization')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const memberOptions: ListMembersOptions = {
|
||||
query: {
|
||||
organizationId: activeOrganization.value.id,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
const { data: response, error } = await organizationApi.listMembers(memberOptions)
|
||||
if (error) {
|
||||
toast.add({ title: 'Error getting members', color: 'error' })
|
||||
console.error('Error getting members:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
activeOrganizationMembers.value = response?.members ?? []
|
||||
}
|
||||
|
||||
return {
|
||||
activeOrganization,
|
||||
activeOrganizationMembers,
|
||||
organizations,
|
||||
createOrganization,
|
||||
deleteOrganization,
|
||||
invitations,
|
||||
getInvitation,
|
||||
inviteMember,
|
||||
removeMember,
|
||||
acceptInvitation,
|
||||
rejectInvitation,
|
||||
cancelSentInvitation,
|
||||
loadOrganizations,
|
||||
setActiveOrganization
|
||||
}
|
||||
})
|
||||
17
legalconsenthub/stores/useUserStore.ts
Normal file
17
legalconsenthub/stores/useUserStore.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Organization } from '~/types/keycloak'
|
||||
|
||||
export const useUserStore = defineStore('Organization', () => {
|
||||
const { user } = useUserSession()
|
||||
const selectedOrganization = computed<Organization | null>(() => {
|
||||
if (!user.value?.organizations || user.value.organizations.length === 0) {
|
||||
return null
|
||||
}
|
||||
return user.value.organizations[0]
|
||||
})
|
||||
|
||||
return {
|
||||
user: user.value,
|
||||
organizations: user.value?.organizations,
|
||||
selectedOrganization
|
||||
}
|
||||
})
|
||||
20
legalconsenthub/types/auth.d.ts
vendored
Normal file
20
legalconsenthub/types/auth.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
declare module '#auth-utils' {
|
||||
interface User {
|
||||
keycloakId: string
|
||||
name: string
|
||||
organizations: Organization[]
|
||||
}
|
||||
|
||||
interface UserSession {
|
||||
name: string
|
||||
organizations: Organization[]
|
||||
loggedInAt: number
|
||||
jwt: {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { RouteLocationRaw } from '#vue-router'
|
||||
import type { useAuthClient } from '~/composables/auth/useAuthClient'
|
||||
|
||||
export interface RuntimeAuthConfig {
|
||||
redirectUserTo: RouteLocationRaw | string
|
||||
redirectGuestTo: RouteLocationRaw | string
|
||||
}
|
||||
|
||||
// Types can be found here: https://github.com/better-auth/better-auth/blob/3f574ec70bb15c155a78673d42c5e25f7376ced3/packages/better-auth/src/plugins/organization/routes/crud-invites.ts#L531
|
||||
type Client = ReturnType<typeof useAuthClient>['client']
|
||||
export type Session = Client['$Infer']['Session']
|
||||
export type User = Session['user']
|
||||
export type ActiveOrganization = Client['$Infer']['ActiveOrganization']
|
||||
export type Organization = Client['$Infer']['Organization']
|
||||
export type Invitation = Client['$Infer']['Invitation']
|
||||
export type Member = Client['$Infer']['Member']
|
||||
export type ListMembersOptions = Parameters<Client['organization']['listMembers']>[0]
|
||||
export type ListMembersResponse = Awaited<ReturnType<Client['organization']['listMembers']>>['data']
|
||||
export type ListMembersQuery = NonNullable<ListMembersOptions>['query']
|
||||
// Extended invitation type with additional organization and inviter details
|
||||
export type CustomInvitation =
|
||||
| (Invitation & {
|
||||
organizationName: string
|
||||
organizationSlug: string
|
||||
inviterEmail: string
|
||||
})
|
||||
| null
|
||||
13
legalconsenthub/types/keycloak.ts
Normal file
13
legalconsenthub/types/keycloak.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface KeycloakTokenPayload {
|
||||
name?: string
|
||||
preferred_username?: string
|
||||
given_name?: string
|
||||
family_name?: string
|
||||
email?: string
|
||||
organization?: Record<string, { id?: string }>
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
name: string
|
||||
id: string
|
||||
}
|
||||
7
legalconsenthub/types/oAuth.ts
Normal file
7
legalconsenthub/types/oAuth.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface OAuthTokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
scope: string
|
||||
}
|
||||
60
legalconsenthub/utils/wrappedFetch.ts
Normal file
60
legalconsenthub/utils/wrappedFetch.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { HTTPMethod } from 'h3'
|
||||
|
||||
// Custom OpenAPI fetch client that wraps useRequestFetch. This ensures that authentication headers
|
||||
// are forwarded correctly during SSR. Unlike fetch, useRequestFetch returns data directly,
|
||||
// so we need to wrap it to mimic the Response object.
|
||||
export const wrappedFetchWrap = (requestFetch: ReturnType<typeof useRequestFetch>) =>
|
||||
async function wrappedFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
try {
|
||||
// Convert RequestInit to $fetch options
|
||||
const fetchOptions: Parameters<typeof $fetch>[1] = {
|
||||
method: (init?.method || 'GET') as HTTPMethod,
|
||||
headers: init?.headers as Record<string, string>
|
||||
}
|
||||
|
||||
if (init?.body) {
|
||||
fetchOptions.body = init.body
|
||||
}
|
||||
|
||||
// Use $fetch to get the data with proper header forwarding
|
||||
const data = await requestFetch(url, fetchOptions)
|
||||
|
||||
// Create a proper Response object
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
console.error('Fetch error:', error)
|
||||
|
||||
// Check if it's a FetchError from ofetch
|
||||
if (error && typeof error === 'object' && 'status' in error) {
|
||||
const fetchError = error as { status?: number; statusText?: string; data?: unknown; message?: string }
|
||||
|
||||
const status = fetchError.status || 500
|
||||
const statusText = fetchError.statusText || fetchError.message || 'Internal Server Error'
|
||||
const errorData = fetchError.data || fetchError.message || 'Unknown error'
|
||||
|
||||
return new Response(JSON.stringify(errorData), {
|
||||
status,
|
||||
statusText,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user