feat(fullstack): Add notifications, user is now an entity, add testcontainers, rework custom permissions, get user from JWT in endpoints

This commit is contained in:
2025-08-09 10:09:00 +02:00
parent a5eae07eaf
commit 7e55a336f2
44 changed files with 1571 additions and 139 deletions

View File

@@ -0,0 +1,89 @@
<template>
<USlideover v-model:open="isOpen" title="Benachrichtigungen">
<template #body>
<div v-if="notifications.length === 0" class="text-center py-8 text-muted">
<UIcon name="i-heroicons-bell-slash" class="h-8 w-8 mx-auto mb-2" />
<p>Keine Benachrichtigungen</p>
</div>
<NuxtLink
v-for="notification in notifications"
:key="notification.id"
:to="notification.clickTarget"
class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3"
@click="handleNotificationClick(notification)"
>
<UChip
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'primary'"
:show="!notification.isRead"
inset
>
<UIcon
:name="
notification.type === 'ERROR'
? 'i-heroicons-x-circle'
: notification.type === 'WARNING'
? 'i-heroicons-exclamation-triangle'
: 'i-heroicons-information-circle'
"
class="h-6 w-6"
/>
</UChip>
<div class="text-sm flex-1">
<p class="flex items-center justify-between">
<span class="text-highlighted font-medium">{{ notification.title }}</span>
<time
:datetime="notification.createdAt.toISOString()"
class="text-muted text-xs"
v-text="formatTimeAgo(notification.createdAt)"
/>
</p>
<p class="text-dimmed">
{{ notification.message }}
</p>
<div class="flex items-center gap-2 mt-1">
<UBadge
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'info'"
variant="subtle"
size="xs"
>
{{ notification.type }}
</UBadge>
<UBadge color="neutral" variant="subtle" size="xs">
{{ notification.targetGroup }}
</UBadge>
</div>
</div>
</NuxtLink>
</template>
</USlideover>
</template>
<script setup lang="ts">
import { formatTimeAgo } from '@vueuse/core'
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const props = defineProps<{
modelValue: boolean
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const { notifications, fetchNotifications, handleNotificationClick } = useNotification()
watch(isOpen, async (newValue) => {
if (newValue) {
await fetchNotifications()
}
})
</script>

View File

@@ -1,2 +1,6 @@
export { useApplicationFormTemplate } from './applicationFormTemplate/useApplicationFormTemplate'
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'

View File

@@ -0,0 +1,118 @@
import type { NotificationDto } from '~/.api-client'
export const useNotification = () => {
const {
getNotifications,
getUnreadNotifications,
getUnreadNotificationCount,
markAllNotificationsAsRead,
markNotificationAsRead
} = useNotificationApi()
const notifications = ref<NotificationDto[]>([])
const unreadNotifications = ref<NotificationDto[]>([])
const unreadCount = ref<number>(0)
const isLoading = ref(false)
const fetchNotifications = async (page: number = 0, size: number = 20) => {
isLoading.value = true
try {
const response = await getNotifications(page, size)
notifications.value = response.content || []
return response
} catch (error) {
console.error('Failed to fetch notifications:', error)
throw error
} finally {
isLoading.value = false
}
}
const fetchUnreadNotifications = async () => {
try {
const response = await getUnreadNotifications()
unreadNotifications.value = response || []
return response
} catch (error) {
console.error('Failed to fetch unread notifications:', error)
throw error
}
}
const fetchUnreadCount = async () => {
try {
const count = await getUnreadNotificationCount()
unreadCount.value = count || 0
return count
} catch (error) {
console.error('Failed to fetch unread count:', error)
throw error
}
}
const markAllAsRead = async () => {
try {
await markAllNotificationsAsRead()
unreadCount.value = 0
unreadNotifications.value = []
notifications.value = notifications.value.map((n) => ({ ...n, isRead: true }))
} catch (error) {
console.error('Failed to mark all as read:', error)
throw error
}
}
const markAsRead = async (notificationId: string) => {
try {
await markNotificationAsRead(notificationId)
const index = notifications.value.findIndex((n) => n.id === notificationId)
if (index !== -1) {
notifications.value[index].isRead = true
}
// Remove from unread notifications
unreadNotifications.value = unreadNotifications.value.filter((n) => n.id !== notificationId)
if (unreadCount.value > 0) {
unreadCount.value--
}
} catch (error) {
console.error('Failed to mark notification as read:', error)
throw error
}
}
const handleNotificationClick = async (notification: NotificationDto) => {
if (!notification.isRead) {
await markAsRead(notification.id)
}
if (notification.clickTarget) {
await navigateTo(notification.clickTarget)
}
}
const startPeriodicRefresh = (intervalMs: number = 30000) => {
const interval = setInterval(() => {
fetchUnreadCount()
}, intervalMs)
onUnmounted(() => {
clearInterval(interval)
})
return interval
}
return {
notifications: readonly(notifications),
unreadNotifications: readonly(unreadNotifications),
unreadCount: readonly(unreadCount),
isLoading: readonly(isLoading),
fetchNotifications,
fetchUnreadNotifications,
fetchUnreadCount,
markAllAsRead,
markAsRead,
handleNotificationClick,
startPeriodicRefresh
}
}

View File

@@ -0,0 +1,55 @@
import {
NotificationApi,
Configuration,
type NotificationDto,
type PagedNotificationDto,
type CreateNotificationDto
} from '~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
export function useNotificationApi() {
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 notificationApiClient = new NotificationApi(
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } })
)
async function createNotification(createNotificationDto: CreateNotificationDto): Promise<NotificationDto> {
return notificationApiClient.createNotification({ createNotificationDto })
}
async function getNotifications(page?: number, size?: number): Promise<PagedNotificationDto> {
return notificationApiClient.getNotifications({ page, size })
}
async function getUnreadNotifications(): Promise<NotificationDto[]> {
return notificationApiClient.getUnreadNotifications()
}
async function getUnreadNotificationCount(): Promise<number> {
return notificationApiClient.getUnreadNotificationCount()
}
async function markAllNotificationsAsRead(): Promise<void> {
return notificationApiClient.markAllNotificationsAsRead()
}
async function markNotificationAsRead(id: string): Promise<NotificationDto> {
return notificationApiClient.markNotificationAsRead({ id })
}
return {
createNotification,
getNotifications,
getUnreadNotifications,
getUnreadNotificationCount,
markAllNotificationsAsRead,
markNotificationAsRead
}
}

View File

@@ -13,6 +13,7 @@ import {
worksCouncilMemberRole,
employeeRole,
adminRole,
ownerRole,
ROLES
} from '~/server/utils/permissions'
@@ -57,7 +58,8 @@ export function useAuth() {
[ROLES.EMPLOYER]: employerRole,
[ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole,
[ROLES.EMPLOYEE]: employeeRole,
[ROLES.ADMIN]: adminRole
[ROLES.ADMIN]: adminRole,
[ROLES.OWNER]: ownerRole
}
}),
jwtClient()

View File

@@ -30,11 +30,11 @@ export function usePermissions() {
)
const canInviteMembers = computed(() =>
hasPermission({ member: ["invite"] })
hasPermission({ invitation: ["create"] })
)
const canManageOrganization = computed(() =>
hasPermission({ organization: ["manage_settings"] })
hasPermission({ organization: ["update"] })
)
// Role checks
@@ -42,6 +42,7 @@ export function usePermissions() {
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 = {
@@ -68,6 +69,12 @@ export function usePermissions() {
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'
}
}
@@ -87,6 +94,7 @@ export function usePermissions() {
isEmployee,
isWorksCouncilMember,
isAdmin,
isOwner,
// Computed permissions
canCreateApplicationForm,

View File

@@ -0,0 +1,58 @@
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 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,
deleteUser
}
}

View File

@@ -0,0 +1,39 @@
import {
UserApi,
Configuration,
type CreateUserDto,
type UserDto
} from '~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
export function useUserApi() {
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 userApiClient = new UserApi(
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } })
)
async function createUser(createUserDto: CreateUserDto): Promise<UserDto> {
return userApiClient.createUser({ createUserDto })
}
async function getUserById(id: string): Promise<UserDto> {
return userApiClient.getUserById({ id })
}
async function deleteUser(id: string): Promise<void> {
return userApiClient.deleteUser({ id })
}
return {
createUser,
getUserById,
deleteUser
}
}

View File

@@ -29,10 +29,25 @@
</UDashboardSidebar>
<slot />
<NotificationsSlideover v-model="isNotificationsSlideoverOpen" />
</UDashboardGroup>
</template>
<script setup lang="ts">
const links = [[], []]
const open = ref(false)
const isNotificationsSlideoverOpen = ref(false)
const { unreadCount, fetchUnreadCount, startPeriodicRefresh } = useNotification()
onMounted(async () => {
await fetchUnreadCount()
startPeriodicRefresh()
})
provide('notificationState', {
isNotificationsSlideoverOpen,
unreadCount
})
</script>

View File

@@ -15,13 +15,15 @@
"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",
"migrate:betterauth": "pnpm dlx @better-auth/cli migrate --config server/utils/auth.ts"
"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": "rm sqlite.db && pnpm run migrate:betterauth && pnpm run migrate:betterauth"
},
"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.4",
"better-sqlite3": "11.8.1",
"nuxt": "3.16.1",

View File

@@ -19,6 +19,16 @@
}"
class="w-48"
/>
<UTooltip text="Notifications" :shortcuts="['N']">
<UButton color="neutral" variant="ghost" square @click="isNotificationsSlideoverOpen = true">
<UChip :show="unreadCount > 0" color="error" inset>
<UIcon name="i-lucide-bell" class="size-5 shrink-0" />
<span v-if="unreadCount > 0" class="ml-1 text-xs">{{ unreadCount }}</span>
</UChip>
</UButton>
</UTooltip>
<UDropdownMenu :items="items">
<UButton icon="i-lucide-plus" size="md" class="rounded-full" />
</UDropdownMenu>
@@ -49,11 +59,7 @@
<p class="text-(--ui-text-muted) text-sm">
Erstellt von {{ applicationFormElem.createdBy.name }} am {{ formatDate(applicationFormElem.createdAt) }}
</p>
<div class="mt-2">
<UChip size="sm">
{{ applicationFormElem.status }}
</UChip>
</div>
<p class="text-(--ui-text-muted) text-sm">Status: {{ applicationFormElem.status }}</p>
</div>
<div>
<UPageLinks :links="getLinksForApplicationForm(applicationFormElem)" />
@@ -77,6 +83,12 @@ const { getAllApplicationForms, deleteApplicationFormById } = useApplicationForm
const route = useRoute()
const { organizations, selectedOrganization } = useAuth()
// Inject notification state from layout
const { isNotificationsSlideoverOpen, unreadCount } = inject('notificationState', {
isNotificationsSlideoverOpen: ref(false),
unreadCount: ref(0)
})
const { data } = await useAsyncData<PagedApplicationFormDto>(
async () => {
if (!selectedOrganization.value) {

View File

@@ -20,6 +20,7 @@
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
import { UserRole } from '~/.api-client'
definePageMeta({ layout: 'auth' })
@@ -27,6 +28,7 @@ useSeoMeta({ title: 'Sign up' })
const toast = useToast()
const { signUp } = useAuth()
const { createUser } = useUser()
const fields = [
{
@@ -90,8 +92,28 @@ function onSubmit(payload: FormSubmitEvent<Schema>) {
// TODO: Hide loading spinner
console.log('Receiving register response')
},
onSuccess: async () => {
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',
role: UserRole.Employee
})
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: (ctx) => {

View File

@@ -17,6 +17,9 @@ importers:
'@pinia/nuxt':
specifier: 0.10.1
version: 0.10.1(magicast@0.3.5)(pinia@3.0.1(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)))
'@vueuse/core':
specifier: ^13.6.0
version: 13.6.0(vue@3.5.13(typescript@5.7.3))
better-auth:
specifier: 1.3.4
version: 1.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -2143,6 +2146,11 @@ packages:
peerDependencies:
vue: ^3.5.0
'@vueuse/core@13.6.0':
resolution: {integrity: sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==}
peerDependencies:
vue: ^3.5.0
'@vueuse/integrations@13.1.0':
resolution: {integrity: sha512-wJ6aANdUs4SOpVabChQK+uLIwxRTUAEmn1DJnflGG7Wq6yaipiRmp6as/Md201FjJnquQt8MecIPbFv8HSBeDA==}
peerDependencies:
@@ -2194,6 +2202,9 @@ packages:
'@vueuse/metadata@13.1.0':
resolution: {integrity: sha512-+TDd7/a78jale5YbHX9KHW3cEDav1lz1JptwDvep2zSG8XjCsVE+9mHIzjTOaPbHUAk5XiE4jXLz51/tS+aKQw==}
'@vueuse/metadata@13.6.0':
resolution: {integrity: sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
@@ -2205,6 +2216,11 @@ packages:
peerDependencies:
vue: ^3.5.0
'@vueuse/shared@13.6.0':
resolution: {integrity: sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==}
peerDependencies:
vue: ^3.5.0
abbrev@3.0.0:
resolution: {integrity: sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -6974,7 +6990,7 @@ snapshots:
'@nuxt/schema': 3.17.2
'@nuxt/ui': 3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.0.10)
'@standard-schema/spec': 1.0.0
'@vueuse/core': 13.1.0(vue@3.5.13(typescript@5.7.3))
'@vueuse/core': 13.6.0(vue@3.5.13(typescript@5.7.3))
consola: 3.4.2
defu: 6.1.4
dotenv: 16.5.0
@@ -6987,7 +7003,7 @@ snapshots:
tinyglobby: 0.2.13
typescript: 5.7.3
unplugin: 2.3.2
unplugin-auto-import: 19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.1.0(vue@3.5.13(typescript@5.7.3)))
unplugin-auto-import: 19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.6.0(vue@3.5.13(typescript@5.7.3)))
unplugin-vue-components: 28.5.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.2(magicast@0.3.5))(vue@3.5.13(typescript@5.7.3))
optionalDependencies:
zod: 4.0.10
@@ -7046,7 +7062,7 @@ snapshots:
'@tailwindcss/vite': 4.1.6(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))
'@tanstack/vue-table': 8.21.3(vue@3.5.13(typescript@5.7.3))
'@unhead/vue': 2.0.8(vue@3.5.13(typescript@5.7.3))
'@vueuse/core': 13.1.0(vue@3.5.13(typescript@5.7.3))
'@vueuse/core': 13.6.0(vue@3.5.13(typescript@5.7.3))
'@vueuse/integrations': 13.1.0(axios@1.7.9)(fuse.js@7.1.0)(vue@3.5.13(typescript@5.7.3))
colortranslator: 4.1.0
consola: 3.4.2
@@ -7072,7 +7088,7 @@ snapshots:
tinyglobby: 0.2.13
typescript: 5.7.3
unplugin: 2.3.2
unplugin-auto-import: 19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.1.0(vue@3.5.13(typescript@5.7.3)))
unplugin-auto-import: 19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.6.0(vue@3.5.13(typescript@5.7.3)))
unplugin-vue-components: 28.5.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.2(magicast@0.3.5))(vue@3.5.13(typescript@5.7.3))
vaul-vue: 0.4.1(reka-ui@2.2.1(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))
vue-component-type-helpers: 2.2.10
@@ -8201,6 +8217,13 @@ snapshots:
'@vueuse/shared': 13.1.0(vue@3.5.13(typescript@5.7.3))
vue: 3.5.13(typescript@5.7.3)
'@vueuse/core@13.6.0(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.6.0
'@vueuse/shared': 13.6.0(vue@3.5.13(typescript@5.7.3))
vue: 3.5.13(typescript@5.7.3)
'@vueuse/integrations@13.1.0(axios@1.7.9)(fuse.js@7.1.0)(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@vueuse/core': 13.1.0(vue@3.5.13(typescript@5.7.3))
@@ -8216,6 +8239,8 @@ snapshots:
'@vueuse/metadata@13.1.0': {}
'@vueuse/metadata@13.6.0': {}
'@vueuse/shared@10.11.1(vue@3.5.13(typescript@5.7.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.13(typescript@5.7.3))
@@ -8233,6 +8258,10 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.7.3)
'@vueuse/shared@13.6.0(vue@3.5.13(typescript@5.7.3))':
dependencies:
vue: 3.5.13(typescript@5.7.3)
abbrev@3.0.0: {}
abort-controller@3.0.0:
@@ -11626,7 +11655,7 @@ snapshots:
universalify@2.0.1: {}
unplugin-auto-import@19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.1.0(vue@3.5.13(typescript@5.7.3))):
unplugin-auto-import@19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.6.0(vue@3.5.13(typescript@5.7.3))):
dependencies:
local-pkg: 1.1.1
magic-string: 0.30.17
@@ -11636,7 +11665,7 @@ snapshots:
unplugin-utils: 0.2.4
optionalDependencies:
'@nuxt/kit': 3.17.2(magicast@0.3.5)
'@vueuse/core': 13.1.0(vue@3.5.13(typescript@5.7.3))
'@vueuse/core': 13.6.0(vue@3.5.13(typescript@5.7.3))
unplugin-utils@0.2.4:
dependencies:

View File

@@ -55,17 +55,39 @@ export const auth = betterAuth({
const roleDisplayName = roleDisplayNames[data.role as LegalRole] || data.role
await resend.emails.send({
from: 'Legal Consent Hub <noreply@legalconsenthub.com>',
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>
`
})
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?.statusCode} ${result.error?.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}`)
}
}
})
]

View File

@@ -1,55 +1,87 @@
import { createAccessControl } from 'better-auth/plugins/access'
import { defaultStatements, adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
import { defu } from 'defu'
export const statement = {
const customStatements = {
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject', 'submit'],
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
organization: ['create', 'read', 'update', 'delete', 'manage_settings'],
member: ['create', 'read', 'update', 'delete', 'invite', 'remove'],
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)
// Roles with specific permissions
export const employerRole = accessControl.newRole({
application_form: ['create', 'read', 'approve', 'reject'],
agreement: ['create', 'read', 'sign', 'approve'],
member: ['invite', 'read'],
comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
})
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({
application_form: ['create', 'read', 'update', 'submit'],
agreement: ['read', 'sign', 'approve'],
member: ['read'],
comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'download', 'upload']
})
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({
application_form: ['read'],
agreement: ['read'],
member: ['read'],
comment: ['create', 'read'],
document: ['read', 'download']
})
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({
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject'],
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
organization: ['create', 'read', 'update', 'delete', 'manage_settings'],
member: ['create', 'read', 'update', 'delete', 'invite', 'remove'],
comment: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
})
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'
ADMIN: 'admin',
OWNER: 'owner'
} as const
export type LegalRole = (typeof ROLES)[keyof typeof ROLES]