major: Migration from better-auth to keycloak

This commit is contained in:
2025-10-28 10:40:38 +01:00
parent e5e063bbde
commit 36364a7977
77 changed files with 1444 additions and 2930 deletions

View File

@@ -339,34 +339,6 @@ paths:
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
####### Users #######
/users:
post:
summary: Create a new user
operationId: createUser
tags:
- user
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserDto"
responses:
"201":
description: User successfully created
content:
application/json:
schema:
$ref: "#/components/schemas/UserDto"
"400":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
/users/{id}:
parameters:
- name: id
@@ -394,35 +366,6 @@ paths:
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
put:
summary: Update a user
operationId: updateUser
description: Updates a user. If no request body is provided, the user data will be synchronized from JWT token claims.
tags:
- user
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/UserDto"
responses:
"200":
description: User successfully updated
content:
application/json:
schema:
$ref: "#/components/schemas/UserDto"
"400":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"404":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/NotFound"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
delete:
summary: Delete a user
operationId: deleteUser
@@ -1075,45 +1018,17 @@ components:
UserDto:
type: object
required:
- id
- keycloakId
- name
- status
- organizationRoles
- organizationId
properties:
id:
keycloakId:
type: string
name:
type: string
status:
$ref: "#/components/schemas/UserStatus"
organizationRoles:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/UserRole"
description: "Map of organization IDs to arrays of user roles in those organizations"
CreateUserDto:
type: object
required:
- id
- name
- status
properties:
id:
organizationId:
type: string
name:
type: string
status:
$ref: "#/components/schemas/UserStatus"
organizationRoles:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/UserRole"
description: "Map of organization IDs to arrays of user roles in those organizations"
nullable: true
UserStatus:
type: string

View File

@@ -49,13 +49,11 @@ dependencies {
implementation "com.openhtmltopdf:openhtmltopdf-slf4j:$openHtmlVersion"
implementation "com.openhtmltopdf:openhtmltopdf-svg-support:$openHtmlVersion"
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'
implementation 'org.postgresql:postgresql'
implementation 'org.springframework.boot:spring-boot-testcontainers'
implementation 'org.testcontainers:postgresql'
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// implementation 'eu.europa.ec.joinup.sd-dss:dss-validation'
// implementation 'eu.europa.ec.joinup.sd-dss:dss-pades'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

View File

@@ -0,0 +1,45 @@
networks:
net:
driver: bridge
volumes:
postgres_data_testbed:
services:
db:
image: postgres:latest
container_name: postgres-testbed
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
env_file:
- .env
ports:
- 5532:5432
networks:
- net
volumes:
- postgres_data_testbed:/var/lib/postgresql/data
keycloak:
image: quay.io/keycloak/keycloak:26.4.0
container_name: keycloak-testbed
command: start-dev
environment:
KC_DB: postgres
KC_DB_URL_HOST: db
KC_DB_USERNAME: ${POSTGRES_USER}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
KC_DB_DATABASE: ${POSTGRES_DB}
KC_DB_SCHEMA: public
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
env_file:
- .env
ports:
- 7080:8080
depends_on:
- db
networks:
- net

View File

@@ -5,7 +5,7 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedExc
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
// import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto
@@ -19,7 +19,7 @@ import java.util.UUID
class ApplicationFormService(
private val applicationFormRepository: ApplicationFormRepository,
private val applicationFormMapper: ApplicationFormMapper,
private val notificationService: NotificationService
// private val notificationService: NotificationService
) {
fun createApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm {
@@ -96,19 +96,19 @@ class ApplicationFormService(
val message = "Ein neuer Mitbestimmungsantrag '${applicationForm.name}' wurde von ${applicationForm.createdBy.name} eingereicht und wartet auf Ihre Bearbeitung."
val clickTarget = "/application-forms/${applicationForm.id}/0"
// Create separate notification for each role that should be notified
val rolesToNotify = listOf("admin", "works_council_member", "employer", "employee")
rolesToNotify.forEach { role ->
notificationService.createNotification(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
role = role,
organizationId = applicationForm.organizationId,
type = NotificationType.INFO
)
}
// // Create separate notification for each role that should be notified
// val rolesToNotify = listOf("admin", "works_council_member", "employer", "employee")
//
// rolesToNotify.forEach { role ->
// notificationService.createNotification(
// title = title,
// message = message,
// clickTarget = clickTarget,
// recipient = null,
// role = role,
// organizationId = applicationForm.organizationId,
// type = NotificationType.INFO
// )
// }
}
}

View File

@@ -18,32 +18,16 @@ import org.springframework.http.HttpMethod
class SecurityConfig {
@Bean
@Order(1)
fun publicApiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher("/swagger-ui/**", "/v3/**", "/actuator/**")
csrf { disable() }
authorizeHttpRequests {
authorize("/swagger-ui/**", permitAll)
authorize("/v3/**", permitAll)
authorize("/actuator/**", permitAll)
authorize(anyRequest, denyAll)
}
}
return http.build()
}
@Bean
@Order(2)
fun protectedApiSecurityFilterChain(
fun configure(
http: HttpSecurity,
customJwtAuthenticationConverter: CustomJwtAuthenticationConverter
): SecurityFilterChain {
http {
csrf { disable() }
authorizeHttpRequests {
// Allow user registration without authentication
authorize(HttpMethod.POST, "/users", permitAll)
authorize("/swagger-ui/**", permitAll)
authorize("/v3/**", permitAll)
authorize("/actuator/**", permitAll)
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
@@ -52,10 +36,4 @@ class SecurityConfig {
}
return http.build()
}
@Bean
fun jwtDecoder(): JwtDecoder {
return NimbusJwtDecoder.withJwkSetUri("http://192.168.178.114:3001/api/auth/jwks")
.jwsAlgorithm(SignatureAlgorithm.ES512).build()
}
}

View File

@@ -1,83 +1,83 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedNotificationDto
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class NotificationController(
private val notificationService: NotificationService,
private val notificationMapper: NotificationMapper,
private val pagedNotificationMapper: PagedNotificationMapper
) : NotificationApi {
override fun getNotifications(page: Int, size: Int): ResponseEntity<PagedNotificationDto> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
val notifications = notificationService.getNotifications(
recipientId = recipientId,
organizationId = organizationId,
page = page,
size = size
)
return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications))
}
override fun getUnreadNotifications(): ResponseEntity<List<NotificationDto>> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
val notifications = notificationService.getUnreadNotifications(
recipientId = recipientId,
organizationId = organizationId
)
return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) })
}
override fun getUnreadNotificationCount(): ResponseEntity<kotlin.Long> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
val count = notificationService.getUnreadNotificationCount(
recipientId = recipientId,
organizationId = organizationId
)
return ResponseEntity.ok(count)
}
override fun markAllNotificationsAsRead(): ResponseEntity<Unit> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
notificationService.markAllAsRead(
recipientId = recipientId,
organizationId = organizationId
)
return ResponseEntity.noContent().build()
}
override fun markNotificationAsRead(id: UUID): ResponseEntity<NotificationDto> {
val notification = notificationService.markNotificationAsRead(id)
return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
}
override fun createNotification(createNotificationDto: CreateNotificationDto): ResponseEntity<NotificationDto> {
val notification = notificationService.createNotification(createNotificationDto)
return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
}
}
// package com.betriebsratkanzlei.legalconsenthub.notification
//
// import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
// import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi
// import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
// import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
// import com.betriebsratkanzlei.legalconsenthub_api.model.PagedNotificationDto
// import org.springframework.http.ResponseEntity
// import org.springframework.security.core.context.SecurityContextHolder
// import org.springframework.web.bind.annotation.RestController
// import java.util.UUID
//
// @RestController
// class NotificationController(
// private val notificationService: NotificationService,
// private val notificationMapper: NotificationMapper,
// private val pagedNotificationMapper: PagedNotificationMapper
// ) : NotificationApi {
//
// override fun getNotifications(page: Int, size: Int): ResponseEntity<PagedNotificationDto> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// val notifications = notificationService.getNotifications(
// recipientId = recipientId,
// organizationId = organizationId,
// page = page,
// size = size
// )
//
// return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications))
// }
//
// override fun getUnreadNotifications(): ResponseEntity<List<NotificationDto>> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// val notifications = notificationService.getUnreadNotifications(
// recipientId = recipientId,
// organizationId = organizationId
// )
//
// return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) })
// }
//
// override fun getUnreadNotificationCount(): ResponseEntity<kotlin.Long> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// val count = notificationService.getUnreadNotificationCount(
// recipientId = recipientId,
// organizationId = organizationId
// )
//
// return ResponseEntity.ok(count)
// }
//
// override fun markAllNotificationsAsRead(): ResponseEntity<Unit> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// notificationService.markAllAsRead(
// recipientId = recipientId,
// organizationId = organizationId
// )
//
// return ResponseEntity.noContent().build()
// }
//
// override fun markNotificationAsRead(id: UUID): ResponseEntity<NotificationDto> {
// val notification = notificationService.markNotificationAsRead(id)
// return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
// }
//
// override fun createNotification(createNotificationDto: CreateNotificationDto): ResponseEntity<NotificationDto> {
// val notification = notificationService.createNotification(createNotificationDto)
// return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
// }
// }

View File

@@ -1,76 +1,84 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface NotificationRepository : JpaRepository<Notification, UUID> {
fun findByRecipientIdOrderByCreatedAtDesc(recipientId: String?, pageable: Pageable): Page<Notification>
fun findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId: String?): List<Notification>
fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long
@Query("""
SELECT n FROM Notification n WHERE
(n.recipient.id = :recipientId) OR
(n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
(n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = ''))
ORDER BY n.createdAt DESC
""")
fun findUserNotificationsByOrgRole(
@Param("recipientId") recipientId: String,
@Param("organizationIds") organizationIds: List<String>,
@Param("orgRolePairs") orgRolePairs: List<String>,
pageable: Pageable
): Page<Notification>
@Query("""
SELECT n FROM Notification n WHERE
((n.recipient.id = :recipientId) OR
(n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
(n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = '')))
AND n.isRead = false
ORDER BY n.createdAt DESC
""")
fun findUnreadUserNotificationsByOrgRole(
@Param("recipientId") recipientId: String,
@Param("organizationIds") organizationIds: List<String>,
@Param("orgRolePairs") orgRolePairs: List<String>
): List<Notification>
@Query("""
SELECT COUNT(n) FROM Notification n WHERE
((n.recipient.id = :recipientId) OR
(n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
(n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = '')))
AND n.isRead = false
""")
fun countUnreadUserNotifications(
@Param("recipientId") recipientId: String,
@Param("organizationIds") organizationIds: List<String>,
@Param("orgRolePairs") orgRolePairs: List<String>
): Long
@Modifying
@Query("""
UPDATE Notification n SET n.isRead = true WHERE
(n.recipient.id = :recipientId) OR
(n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
(n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = ''))
""")
fun markAllUserNotificationsAsRead(
@Param("recipientId") recipientId: String,
@Param("organizationIds") organizationIds: List<String>,
@Param("orgRolePairs") orgRolePairs: List<String>
)
@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.id = :recipientId")
fun markAllAsReadByRecipientId(@Param("recipientId") recipientId: String)
}
// package com.betriebsratkanzlei.legalconsenthub.notification
//
// import org.springframework.data.domain.Page
// import org.springframework.data.domain.Pageable
// import org.springframework.data.jpa.repository.JpaRepository
// import org.springframework.data.jpa.repository.Modifying
// import org.springframework.data.jpa.repository.Query
// import org.springframework.data.repository.query.Param
// import org.springframework.stereotype.Repository
// import java.util.UUID
//
// @Repository
// interface NotificationRepository : JpaRepository<Notification, UUID> {
//
// fun findByRecipientIdOrderByCreatedAtDesc(recipientId: String?, pageable: Pageable): Page<Notification>
// fun findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId: String?): List<Notification>
// fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long
//
// @Query(
// """
// SELECT n FROM Notification n WHERE
// (n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = ''))
// ORDER BY n.createdAt DESC
// """
// )
// fun findUserNotificationsByOrgRole(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>,
// pageable: Pageable
// ): Page<Notification>
//
// @Query(
// """
// SELECT n FROM Notification n WHERE
// ((n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = '')))
// AND n.isRead = false
// ORDER BY n.createdAt DESC
// """
// )
// fun findUnreadUserNotificationsByOrgRole(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>
// ): List<Notification>
//
// @Query(
// """
// SELECT COUNT(n) FROM Notification n WHERE
// ((n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = '')))
// AND n.isRead = false
// """
// )
// fun countUnreadUserNotifications(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>
// ): Long
//
// @Modifying
// @Query(
// """
// UPDATE Notification n SET n.isRead = true WHERE
// (n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = ''))
// """
// )
// fun markAllUserNotificationsAsRead(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>
// )
//
// @Modifying
// @Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.keycloakId = :recipientId")
// fun markAllAsReadByRecipientId(@Param("recipientId") recipientId: String)
// }

View File

@@ -1,127 +1,127 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub.user.UserRepository
import com.betriebsratkanzlei.legalconsenthub.user.UserRoleConverter
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service
class NotificationService(
private val notificationRepository: NotificationRepository,
private val notificationMapper: NotificationMapper,
private val userRepository: UserRepository,
private val userRoleConverter: UserRoleConverter
) {
fun createNotification(createNotificationDto: CreateNotificationDto): Notification {
val notification = notificationMapper.toNotification(createNotificationDto)
return notificationRepository.save(notification)
}
fun createNotification(
title: String,
message: String,
clickTarget: String,
recipient: User?,
role: String,
organizationId: String,
type: NotificationType = NotificationType.INFO
): Notification {
val notification = Notification(
title = title,
message = message,
clickTarget = clickTarget,
recipient = recipient,
role = role,
type = type,
organizationId = organizationId
)
return notificationRepository.save(notification)
}
fun getNotifications(
recipientId: String,
organizationId: String,
page: Int = 0,
size: Int = 20
): Page<Notification> {
val user = userRepository.findById(recipientId)
.orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
val pageable = PageRequest.of(page, size)
return if (userRoles.isNotEmpty()) {
notificationRepository.findUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs, pageable)
} else {
notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable)
}
}
fun getUnreadNotifications(
recipientId: String,
organizationId: String
): List<Notification> {
val user = userRepository.findById(recipientId)
.orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
return if (userRoles.isNotEmpty()) {
notificationRepository.findUnreadUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs)
} else {
notificationRepository.findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId)
}
}
fun getUnreadNotificationCount(
recipientId: String,
organizationId: String
): Long {
val user = userRepository.findById(recipientId)
.orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
return if (userRoles.isNotEmpty()) {
notificationRepository.countUnreadUserNotifications(recipientId, listOf(organizationId), orgRolePairs)
} else {
notificationRepository.countByRecipientIdAndIsReadFalse(recipientId)
}
}
@Transactional
fun markAllAsRead(
recipientId: String,
organizationId: String
) {
val user = userRepository.findById(recipientId)
.orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
if (userRoles.isNotEmpty()) {
notificationRepository.markAllUserNotificationsAsRead(recipientId, listOf(organizationId), orgRolePairs)
} else {
notificationRepository.markAllAsReadByRecipientId(recipientId)
}
}
@Transactional
fun markNotificationAsRead(notificationId: UUID): Notification {
val notification = notificationRepository.findById(notificationId)
.orElseThrow { IllegalArgumentException("Notification not found with id: $notificationId") }
notification.isRead = true
return notificationRepository.save(notification)
}
}
// package com.betriebsratkanzlei.legalconsenthub.notification
//
// import com.betriebsratkanzlei.legalconsenthub.user.User
// import com.betriebsratkanzlei.legalconsenthub.user.UserRepository
// import com.betriebsratkanzlei.legalconsenthub.user.UserRoleConverter
// import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
// import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
// import org.springframework.data.domain.Page
// import org.springframework.data.domain.PageRequest
// import org.springframework.stereotype.Service
// import org.springframework.transaction.annotation.Transactional
// import java.util.UUID
//
// @Service
// class NotificationService(
// private val notificationRepository: NotificationRepository,
// private val notificationMapper: NotificationMapper,
// private val userRepository: UserRepository,
// private val userRoleConverter: UserRoleConverter
// ) {
//
// fun createNotification(createNotificationDto: CreateNotificationDto): Notification {
// val notification = notificationMapper.toNotification(createNotificationDto)
// return notificationRepository.save(notification)
// }
//
// fun createNotification(
// title: String,
// message: String,
// clickTarget: String,
// recipient: User?,
// role: String,
// organizationId: String,
// type: NotificationType = NotificationType.INFO
// ): Notification {
// val notification = Notification(
// title = title,
// message = message,
// clickTarget = clickTarget,
// recipient = recipient,
// role = role,
// type = type,
// organizationId = organizationId
// )
// return notificationRepository.save(notification)
// }
//
// fun getNotifications(
// recipientId: String,
// organizationId: String,
// page: Int = 0,
// size: Int = 20
// ): Page<Notification> {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// val pageable = PageRequest.of(page, size)
// return if (userRoles.isNotEmpty()) {
// notificationRepository.findUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs, pageable)
// } else {
// notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable)
// }
// }
//
// fun getUnreadNotifications(
// recipientId: String,
// organizationId: String
// ): List<Notification> {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// return if (userRoles.isNotEmpty()) {
// notificationRepository.findUnreadUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs)
// } else {
// notificationRepository.findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId)
// }
// }
//
// fun getUnreadNotificationCount(
// recipientId: String,
// organizationId: String
// ): Long {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// return if (userRoles.isNotEmpty()) {
// notificationRepository.countUnreadUserNotifications(recipientId, listOf(organizationId), orgRolePairs)
// } else {
// notificationRepository.countByRecipientIdAndIsReadFalse(recipientId)
// }
// }
//
// @Transactional
// fun markAllAsRead(
// recipientId: String,
// organizationId: String
// ) {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// if (userRoles.isNotEmpty()) {
// notificationRepository.markAllUserNotificationsAsRead(recipientId, listOf(organizationId), orgRolePairs)
// } else {
// notificationRepository.markAllAsReadByRecipientId(recipientId)
// }
// }
//
// @Transactional
// fun markNotificationAsRead(notificationId: UUID): Notification {
// val notification = notificationRepository.findById(notificationId)
// .orElseThrow { IllegalArgumentException("Notification not found with id: $notificationId") }
// notification.isRead = true
// return notificationRepository.save(notification)
// }
// }

View File

@@ -11,12 +11,12 @@ class CustomJwtAuthenticationConverter : Converter<Jwt, AbstractAuthenticationTo
override fun convert(jwt: Jwt): AbstractAuthenticationToken {
val authorities: Collection<GrantedAuthority> = emptyList()
val userId = jwt.getClaimAsString("id")
val userId = jwt.subject
val username = jwt.getClaimAsString("name")
val organizationId = jwt.getClaimAsString("organizationId")
val roles = jwt.getClaimAsStringList("roles") ?: emptyList()
val realmAccess = jwt.getClaimAsMap("realm_access")
val roles = (realmAccess?.get("roles") as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
val principal = CustomJwtTokenPrincipal(userId, username, organizationId, roles)
val principal = CustomJwtTokenPrincipal(userId, username, roles)
return CustomJwtAuthentication(jwt, principal, authorities)
}

View File

@@ -3,6 +3,6 @@ package com.betriebsratkanzlei.legalconsenthub.security
data class CustomJwtTokenPrincipal(
val id: String? = null,
val name: String? = null,
val roles: List<String> = emptyList(),
val organizationId: String? = null,
val roles: List<String> = emptyList()
)

View File

@@ -0,0 +1,55 @@
package com.betriebsratkanzlei.legalconsenthub.security
import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
@Component
class JwtUserSyncFilter(val userService: UserService) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val logger = LoggerFactory.getLogger(JwtUserSyncFilter::class.java)
val auth: Authentication? = SecurityContextHolder.getContext().authentication
println("JwtUserSyncFilter invoked for request: ${request.requestURI}")
try {
if (auth is JwtAuthenticationToken && auth.isAuthenticated) {
val jwt: Jwt = auth.token
val keycloakId = jwt.subject
val name = jwt.getClaimAsString("name")
// Extract organization information from JWT
val organizationClaim = jwt.getClaimAsMap("organization")
val organizationId = organizationClaim?.values?.firstOrNull()?.let { orgData ->
if (orgData is Map<*, *>) {
orgData["id"] as? String
} else {
null
}
}
val user = UserDto(keycloakId, name, organizationId)
if (keycloakId != null) {
userService.createUpdateUserFromJwt(user)
}
}
} catch (ex: Exception) {
logger.warn("Failed to sync user from JWT: {}", ex.message, ex)
}
filterChain.doFilter(request, response)
}
}

View File

@@ -13,18 +13,13 @@ import java.time.LocalDateTime
class User(
@Id
@Column(nullable = false)
var id: String,
var keycloakId: String,
@Column(nullable = false)
var name: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var status: UserStatus = UserStatus.ACTIVE,
@ElementCollection
@CollectionTable(name = "user_organization_roles", joinColumns = [JoinColumn(name = "user_id")])
var organizationRoles: MutableSet<UserOrganizationRole> = mutableSetOf(),
@Column(nullable = true)
var organizationId: String? = null,
@CreatedDate
@Column(nullable = false)

View File

@@ -1,11 +1,8 @@
package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.api.UserApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.RestController
@RestController
@@ -13,33 +10,11 @@ class UserController(
private val userService: UserService,
private val userMapper: UserMapper
) : UserApi {
override fun createUser(createUserDto: CreateUserDto): ResponseEntity<UserDto> {
val user = userService.createUser(createUserDto)
return ResponseEntity.status(201).body(userMapper.toUserDto(user))
}
override fun getUserById(id: String): ResponseEntity<UserDto> {
val user = userService.getUserById(id)
return ResponseEntity.ok(userMapper.toUserDto(user))
}
override fun updateUser(id: String, userDto: UserDto?): ResponseEntity<UserDto> {
val user = if (userDto != null) {
// Update with provided data
userService.updateUser(id, userDto)
} else {
// Update from JWT data
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val userId = principal.id ?: throw IllegalArgumentException("User ID missing from JWT")
val organizationId = principal.organizationId
val roles = principal.roles
userService.updateUserFromJwt(userId, organizationId, roles)
}
return ResponseEntity.ok(userMapper.toUserDto(user))
}
override fun deleteUser(id: String): ResponseEntity<Unit> {
userService.deleteUser(id)
return ResponseEntity.noContent().build()

View File

@@ -4,31 +4,22 @@ import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import org.springframework.stereotype.Component
@Component
class UserMapper(
private val roleConverter: UserRoleConverter
) {
class UserMapper {
fun toUserDto(user: User): UserDto {
val organizationRolesDto = roleConverter.convertToMap(user.organizationRoles)
return UserDto(
id = user.id,
keycloakId = user.keycloakId,
name = user.name,
status = user.status,
organizationRoles = organizationRolesDto
organizationId = user.organizationId
)
}
fun toUser(userDto: UserDto): User {
val user = User(
id = userDto.id,
keycloakId = userDto.keycloakId,
name = userDto.name,
status = userDto.status
organizationId = userDto.organizationId
)
userDto.organizationRoles.forEach { (orgId, roles) ->
roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles)
}
return user
}
}

View File

@@ -1,13 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.user
import jakarta.persistence.Column
import jakarta.persistence.Embeddable
@Embeddable
data class UserOrganizationRole(
@Column(name = "organization_id", nullable = false)
val organizationId: String,
@Column(name = "role", nullable = false)
val role: String
)

View File

@@ -1,42 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub_api.model.UserRole
import org.springframework.stereotype.Component
@Component
object UserRoleConverter {
fun getRolesForOrganization(organizationRoles: Set<UserOrganizationRole>, organizationId: String): List<UserRole> {
return organizationRoles
.filter { it.organizationId == organizationId }
.mapNotNull { orgRole ->
try {
UserRole.valueOf(orgRole.role)
} catch (e: IllegalArgumentException) {
null
}
}
}
fun setRolesForOrganization(organizationRoles: MutableSet<UserOrganizationRole>, organizationId: String, roles: List<UserRole>) {
organizationRoles.removeIf { it.organizationId == organizationId }
roles.forEach { role ->
organizationRoles.add(UserOrganizationRole(organizationId, role.name))
}
}
fun convertToMap(organizationRoles: Set<UserOrganizationRole>): Map<String, List<UserRole>> {
return organizationRoles
.groupBy { it.organizationId }
.mapValues { (_, roles) ->
roles.mapNotNull { orgRole ->
try {
UserRole.valueOf(orgRole.role)
} catch (e: IllegalArgumentException) {
null
}
}
}
.filterValues { it.isNotEmpty() }
}
}

View File

@@ -3,9 +3,7 @@ package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub.error.UserAlreadyExistsException
import com.betriebsratkanzlei.legalconsenthub.error.UserNotFoundException
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus
import jakarta.transaction.Transactional
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
@@ -13,7 +11,7 @@ import org.springframework.stereotype.Service
@Service
class UserService(
private val userRepository: UserRepository,
private val roleConverter: UserRoleConverter
private val userMapper: UserMapper
) {
fun getCurrentUser(): User {
@@ -24,21 +22,32 @@ class UserService(
.orElseThrow { UserNotFoundException(userId) }
}
fun createUser(createUserDto: CreateUserDto): User {
if (userRepository.existsById(createUserDto.id)) {
throw UserAlreadyExistsException(createUserDto.id)
@Transactional
fun createUpdateUserFromJwt(userDto: UserDto): User {
val existingUser = userRepository.findById(userDto.keycloakId)
if (existingUser.isEmpty) {
return createUser(userDto)
} else {
val user = existingUser.get()
if (user.organizationId == null && userDto.organizationId != null) {
user.organizationId = userDto.organizationId
}
return updateUser(userMapper.toUserDto(user))
}
}
fun createUser(userDto: UserDto): User {
if (userRepository.existsById(userDto.keycloakId)) {
throw UserAlreadyExistsException(userDto.keycloakId)
}
val user = User(
id = createUserDto.id,
name = createUserDto.name,
status = createUserDto.status
keycloakId = userDto.keycloakId,
name = userDto.name,
organizationId = userDto.organizationId
)
createUserDto.organizationRoles?.forEach { (orgId, roles) ->
roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles)
}
return userRepository.save(user)
}
@@ -48,43 +57,19 @@ class UserService(
}
@Transactional
fun updateUser(userId: String, userDto: UserDto): User {
val user = userRepository.findById(userId)
.orElseThrow { UserNotFoundException(userId) }
fun updateUser(userDto: UserDto): User {
val user = userRepository.findById(userDto.keycloakId)
.orElseThrow { UserNotFoundException(userDto.keycloakId) }
user.name = userDto.name
user.status = userDto.status
user.organizationRoles.clear()
userDto.organizationRoles.forEach { (orgId, roles) ->
roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles)
// Only update organization if it's not already set
if (user.organizationId == null && userDto.organizationId != null) {
user.organizationId = userDto.organizationId
}
return userRepository.save(user)
}
@Transactional
fun updateUserFromJwt(userId: String, jwtOrganizationId: String?, jwtRoles: List<String>?): User {
val existingUser = userRepository.findById(userId)
.orElseThrow { UserNotFoundException(userId) }
if (jwtOrganizationId != null && !jwtRoles.isNullOrEmpty()) {
existingUser.organizationRoles.removeIf { it.organizationId == jwtOrganizationId }
jwtRoles.forEach { role ->
val normalizedRole = role.lowercase().replace("_", "_")
existingUser.organizationRoles.add(
UserOrganizationRole(
organizationId = jwtOrganizationId,
role = normalizedRole
)
)
}
}
return userRepository.save(existingUser)
}
fun deleteUser(userId: String) {
userRepository.deleteById(userId)
}

View File

@@ -32,6 +32,13 @@ spring:
init:
platform: h2
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:7080/realms/legalconsenthub
jwk-set-uri: http://localhost:7080/realms/legalconsenthub/protocol/openid-connect/certs
logging:
level:
org:

View File

@@ -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

View File

@@ -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>

View File

@@ -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"));

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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 })
}
}
]

View File

@@ -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)
}
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)
}
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)
}
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)
}
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)
}
return Promise.reject(e)
}
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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(

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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'

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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> {

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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>
</div>
</template>

View File

@@ -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>

View File

@@ -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')
}
})

View 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
}

View File

@@ -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']
}
}
},

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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({

View 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>

View File

@@ -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)

View File

@@ -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
}
})

View File

@@ -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>.
<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>.
<div class="text-center text-xs text-gray-500">
By signing in, you agree to our terms of service
</div>
</template>
</UAuthForm>
</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>

View File

@@ -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>

View File

@@ -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()
})
}
})

View File

@@ -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()
}
}
})

View File

@@ -6,8 +6,8 @@ export default defineNuxtPlugin(() => {
// 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)
// })
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -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}`
}
})
})

View File

@@ -1,6 +0,0 @@
import { auth } from '../../utils/auth'
import type { H3Event } from 'h3'
export default defineEventHandler((event: H3Event) => {
return auth.handler(toWebRequest(event))
})

View 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'
})
}
})

View 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
}

View 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)
})

View File

@@ -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 }

View File

@@ -1,3 +0,0 @@
import { Resend } from 'resend'
export const resend = new Resend(process.env.RESEND_API_KEY)

View File

@@ -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.

View File

@@ -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
}
})

View 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
View 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 {}

View File

@@ -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

View 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
}

View File

@@ -0,0 +1,7 @@
export interface OAuthTokenResponse {
access_token: string
refresh_token: string
token_type: string
expires_in: number
scope: string
}

View 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'
}
})
}
}
}