diff --git a/legalconsenthub-backend/api/legalconsenthub.yml b/legalconsenthub-backend/api/legalconsenthub.yml index dc4b1c3..5388762 100644 --- a/legalconsenthub-backend/api/legalconsenthub.yml +++ b/legalconsenthub-backend/api/legalconsenthub.yml @@ -394,6 +394,35 @@ 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 @@ -1049,6 +1078,7 @@ components: - id - name - status + - organizationRoles properties: id: type: string @@ -1056,8 +1086,13 @@ components: type: string status: $ref: "#/components/schemas/UserStatus" - role: - $ref: "#/components/schemas/UserRole" + 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 @@ -1065,7 +1100,6 @@ components: - id - name - status - - role properties: id: type: string @@ -1073,8 +1107,13 @@ components: type: string status: $ref: "#/components/schemas/UserStatus" - role: - $ref: "#/components/schemas/UserRole" + organizationRoles: + type: object + additionalProperties: + type: array + items: + $ref: "#/components/schemas/UserRole" + description: "Map of organization IDs to arrays of user roles in those organizations" UserStatus: type: string @@ -1176,9 +1215,10 @@ components: - message - clickTarget - isRead - - targetGroup + - role - type - createdAt + - organizationId properties: id: type: string @@ -1195,10 +1235,12 @@ components: nullable: true allOf: - $ref: "#/components/schemas/UserDto" - targetGroup: + role: type: string type: $ref: "#/components/schemas/NotificationType" + organizationId: + type: string createdAt: type: string format: date-time @@ -1209,8 +1251,9 @@ components: - title - message - clickTarget - - targetGroup + - role - type + - organizationId properties: title: type: string @@ -1222,10 +1265,12 @@ components: nullable: true allOf: - $ref: "#/components/schemas/UserDto" - targetGroup: + role: type: string type: $ref: "#/components/schemas/NotificationType" + organizationId: + type: string PagedNotificationDto: type: object diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt index 6ad4d86..e970b84 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt @@ -96,44 +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 notification for admin users - notificationService.createNotificationForUser( - title = title, - message = message, - clickTarget = clickTarget, - recipient = null, - targetGroup = "admin", - type = NotificationType.INFO - ) + // Create separate notification for each role that should be notified + val rolesToNotify = listOf("admin", "works_council_member", "employer", "employee") - // Create notification for works council members - notificationService.createNotificationForUser( - title = title, - message = message, - clickTarget = clickTarget, - recipient = null, - targetGroup = "works_council_member", - type = NotificationType.INFO - ) - - // Create notification for employer - notificationService.createNotificationForUser( - title = title, - message = message, - clickTarget = clickTarget, - recipient = null, - targetGroup = "employer", - type = NotificationType.INFO - ) - - // Create notification for employee - notificationService.createNotificationForUser( - title = title, - message = message, - clickTarget = clickTarget, - recipient = null, - targetGroup = "employee", - type = NotificationType.INFO - ) + rolesToNotify.forEach { role -> + notificationService.createNotification( + title = title, + message = message, + clickTarget = clickTarget, + recipient = null, + role = role, + organizationId = applicationForm.organizationId, + type = NotificationType.INFO + ) + } } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/config/SecurityConfig.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/config/SecurityConfig.kt index 7913d03..00273b3 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/config/SecurityConfig.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/config/SecurityConfig.kt @@ -1,5 +1,6 @@ package com.betriebsratkanzlei.legalconsenthub.config +import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtAuthenticationConverter import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.annotation.Order @@ -20,14 +21,12 @@ class SecurityConfig { @Order(1) fun publicApiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { http { - securityMatcher("/swagger-ui/**", "/v3/**", "/actuator/**", "/users") + securityMatcher("/swagger-ui/**", "/v3/**", "/actuator/**") csrf { disable() } authorizeHttpRequests { authorize("/swagger-ui/**", permitAll) authorize("/v3/**", permitAll) authorize("/actuator/**", permitAll) - // For user registration - authorize(HttpMethod.POST, "/users", permitAll) authorize(anyRequest, denyAll) } } @@ -43,6 +42,8 @@ class SecurityConfig { http { csrf { disable() } authorizeHttpRequests { + // Allow user registration without authentication + authorize(HttpMethod.POST, "/users", permitAll) authorize(anyRequest, authenticated) } oauth2ResourceServer { diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt index 6a401df..5d8db16 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt @@ -40,12 +40,15 @@ class Notification( var recipient: User?, @Column(nullable = false) - var targetGroup: String = "", + var role: String = "", @Enumerated(EnumType.STRING) @Column(nullable = false) var type: NotificationType = NotificationType.INFO, + @Column(nullable = false) + var organizationId: String = "", + @CreatedDate @Column(nullable = false) var createdAt: LocalDateTime? = null diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationController.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationController.kt index 43e6213..e6b2ec9 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationController.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationController.kt @@ -1,7 +1,6 @@ package com.betriebsratkanzlei.legalconsenthub.notification import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal -import com.betriebsratkanzlei.legalconsenthub.user.UserService import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto @@ -15,30 +14,20 @@ import java.util.UUID class NotificationController( private val notificationService: NotificationService, private val notificationMapper: NotificationMapper, - private val pagedNotificationMapper: PagedNotificationMapper, - private val userService: UserService + private val pagedNotificationMapper: PagedNotificationMapper ) : NotificationApi { override fun getNotifications(page: Int, size: Int): ResponseEntity { 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 user = userService.getUserById(recipientId) - - val notifications = if (user.role != null) { - notificationService.getNotificationsForUserAndGroup( - recipientId = recipientId, - userRole = user.role!!.value, - page = page, - size = size - ) - } else { - notificationService.getNotificationsForUser( - recipientId = recipientId, - page = page, - size = size - ) - } + val notifications = notificationService.getNotifications( + recipientId = recipientId, + organizationId = organizationId, + page = page, + size = size + ) return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications)) } @@ -46,29 +35,25 @@ class NotificationController( override fun getUnreadNotifications(): ResponseEntity> { 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 user = userService.getUserById(recipientId) - - val notifications = if (user.role != null) { - notificationService.getUnreadNotificationsForUserAndGroup(recipientId, user.role!!.value) - } else { - notificationService.getUnreadNotificationsForUser(recipientId) - } + val notifications = notificationService.getUnreadNotifications( + recipientId = recipientId, + organizationId = organizationId + ) return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) }) } - override fun getUnreadNotificationCount(): ResponseEntity { + override fun getUnreadNotificationCount(): ResponseEntity { 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 user = userService.getUserById(recipientId) - - val count = if (user.role != null) { - notificationService.getUnreadNotificationCountForUserAndGroup(recipientId, user.role!!.value) - } else { - notificationService.getUnreadNotificationCount(recipientId) - } + val count = notificationService.getUnreadNotificationCount( + recipientId = recipientId, + organizationId = organizationId + ) return ResponseEntity.ok(count) } @@ -76,22 +61,18 @@ class NotificationController( override fun markAllNotificationsAsRead(): ResponseEntity { 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 user = userService.getUserById(recipientId) - - if (user.role != null) { - notificationService.markAllAsReadForUserAndGroup(recipientId, user.role!!.value) - } else { - notificationService.markAllAsRead(recipientId) - } + notificationService.markAllAsRead( + recipientId = recipientId, + organizationId = organizationId + ) return ResponseEntity.noContent().build() } override fun markNotificationAsRead(id: UUID): ResponseEntity { - val notification = notificationService.markAsRead(id) - ?: return ResponseEntity.notFound().build() - + val notification = notificationService.markNotificationAsRead(id) return ResponseEntity.ok(notificationMapper.toNotificationDto(notification)) } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationMapper.kt index fc273b9..9be7d84 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationMapper.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationMapper.kt @@ -18,8 +18,9 @@ class NotificationMapper( clickTarget = notification.clickTarget, isRead = notification.isRead, recipient = notification.recipient?.let { userMapper.toUserDto(it) }, - targetGroup = notification.targetGroup, + role = notification.role, type = notification.type, + organizationId = notification.organizationId, createdAt = notification.createdAt!! ) } @@ -31,8 +32,9 @@ class NotificationMapper( clickTarget = createNotificationDto.clickTarget, isRead = false, recipient = createNotificationDto.recipient?.let { userMapper.toUser(it) }, - targetGroup = createNotificationDto.targetGroup, - type = createNotificationDto.type + role = createNotificationDto.role, + type = createNotificationDto.type, + organizationId = createNotificationDto.organizationId ) } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationRepository.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationRepository.kt index e6fffae..667dce1 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationRepository.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationRepository.kt @@ -13,35 +13,64 @@ import java.util.UUID interface NotificationRepository : JpaRepository { fun findByRecipientIdOrderByCreatedAtDesc(recipientId: String?, pageable: Pageable): Page - fun findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId: String?): List + fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long - fun findByRecipientIsNullOrderByCreatedAtDesc(pageable: Pageable): Page + @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, + @Param("orgRolePairs") orgRolePairs: List, + pageable: Pageable + ): Page - fun findByRecipientIsNullAndIsReadFalseOrderByCreatedAtDesc(): List + @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, + @Param("orgRolePairs") orgRolePairs: List + ): List - @Query("SELECT n FROM Notification n WHERE (n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all') ORDER BY n.createdAt DESC") - fun findByRecipientIdOrTargetGroupOrderByCreatedAtDesc(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String, pageable: Pageable): Page + @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, + @Param("orgRolePairs") orgRolePairs: List + ): Long - @Query("SELECT n FROM Notification n WHERE ((n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all')) AND n.isRead = false ORDER BY n.createdAt DESC") - fun findByRecipientIdOrTargetGroupAndIsReadFalseOrderByCreatedAtDesc(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String): List - - @Query("SELECT COUNT(n) FROM Notification n WHERE ((n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all')) AND n.isRead = false") - fun countByRecipientIdOrTargetGroupAndIsReadFalse(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: 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, + @Param("orgRolePairs") orgRolePairs: List + ) @Modifying @Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.id = :recipientId") fun markAllAsReadByRecipientId(@Param("recipientId") recipientId: String) - - @Modifying - @Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient IS NULL") - fun markAllAsReadForNullRecipients() - - @Modifying - @Query("UPDATE Notification n SET n.isRead = true WHERE (n.recipient.id = :recipientId) OR (n.recipient IS NULL AND n.targetGroup = :targetGroup) OR (n.recipient IS NULL AND n.targetGroup = 'all')") - fun markAllAsReadByRecipientIdOrTargetGroup(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String) - - fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long - - fun countByRecipientIsNullAndIsReadFalse(): Long } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationService.kt index 3544450..c9d644f 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/NotificationService.kt @@ -1,6 +1,8 @@ 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 @@ -12,7 +14,9 @@ import java.util.UUID @Service class NotificationService( private val notificationRepository: NotificationRepository, - private val notificationMapper: NotificationMapper + private val notificationMapper: NotificationMapper, + private val userRepository: UserRepository, + private val userRoleConverter: UserRoleConverter ) { fun createNotification(createNotificationDto: CreateNotificationDto): Notification { @@ -20,12 +24,13 @@ class NotificationService( return notificationRepository.save(notification) } - fun createNotificationForUser( + fun createNotification( title: String, message: String, clickTarget: String, recipient: User?, - targetGroup: String, + role: String, + organizationId: String, type: NotificationType = NotificationType.INFO ): Notification { val notification = Notification( @@ -33,55 +38,90 @@ class NotificationService( message = message, clickTarget = clickTarget, recipient = recipient, - targetGroup = targetGroup, - type = type + role = role, + type = type, + organizationId = organizationId ) return notificationRepository.save(notification) } - fun getNotificationsForUser(recipientId: String, page: Int = 0, size: Int = 20): Page { + fun getNotifications( + recipientId: String, + organizationId: String, + page: Int = 0, + size: Int = 20 + ): Page { + 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 notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable) - } - - fun getNotificationsForUserAndGroup(recipientId: String, userRole: String, page: Int = 0, size: Int = 20): Page { - val pageable = PageRequest.of(page, size) - return notificationRepository.findByRecipientIdOrTargetGroupOrderByCreatedAtDesc(recipientId, userRole, pageable) - } - - fun getUnreadNotificationsForUser(recipientId: String): List { - return notificationRepository.findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId) - } - - fun getUnreadNotificationsForUserAndGroup(recipientId: String, userRole: String): List { - return notificationRepository.findByRecipientIdOrTargetGroupAndIsReadFalseOrderByCreatedAtDesc(recipientId, userRole) - } - - fun getUnreadNotificationCount(recipientId: String): Long { - return notificationRepository.countByRecipientIdAndIsReadFalse(recipientId) - } - - fun getUnreadNotificationCountForUserAndGroup(recipientId: String, userRole: String): Long { - return notificationRepository.countByRecipientIdOrTargetGroupAndIsReadFalse(recipientId, userRole) - } - - @Transactional - fun markAllAsRead(recipientId: String) { - notificationRepository.markAllAsReadByRecipientId(recipientId) - } - - @Transactional - fun markAllAsReadForUserAndGroup(recipientId: String, userRole: String) { - notificationRepository.markAllAsReadByRecipientIdOrTargetGroup(recipientId, userRole) - } - - fun markAsRead(notificationId: UUID): Notification? { - val notification = notificationRepository.findById(notificationId).orElse(null) - return if (notification != null) { - notification.isRead = true - notificationRepository.save(notification) + return if (userRoles.isNotEmpty()) { + notificationRepository.findUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs, pageable) } else { - null + notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable) } } + + fun getUnreadNotifications( + recipientId: String, + organizationId: String + ): List { + 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) + } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/config/CustomJwtAuthenticationConverter.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/security/CustomJwtAuthenticationConverter.kt similarity index 77% rename from legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/config/CustomJwtAuthenticationConverter.kt rename to legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/security/CustomJwtAuthenticationConverter.kt index 90d437a..9d0397a 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/config/CustomJwtAuthenticationConverter.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/security/CustomJwtAuthenticationConverter.kt @@ -1,7 +1,5 @@ -package com.betriebsratkanzlei.legalconsenthub.config +package com.betriebsratkanzlei.legalconsenthub.security -import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtAuthentication -import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal import org.springframework.core.convert.converter.Converter import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.core.GrantedAuthority @@ -15,7 +13,10 @@ class CustomJwtAuthenticationConverter : Converter = emptyList() ) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt index c33d5f2..0813661 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt @@ -1,6 +1,5 @@ package com.betriebsratkanzlei.legalconsenthub.user -import com.betriebsratkanzlei.legalconsenthub_api.model.UserRole import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus import jakarta.persistence.* import org.springframework.data.annotation.CreatedDate @@ -22,10 +21,10 @@ class User( @Enumerated(EnumType.STRING) @Column(nullable = false) var status: UserStatus = UserStatus.ACTIVE, - - @Enumerated(EnumType.STRING) - @Column(nullable = true) - var role: UserRole? = null, + + @ElementCollection + @CollectionTable(name = "user_organization_roles", joinColumns = [JoinColumn(name = "user_id")]) + var organizationRoles: MutableSet = mutableSetOf(), @CreatedDate @Column(nullable = false) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserController.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserController.kt index 8019ab7..870f09e 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserController.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserController.kt @@ -1,9 +1,11 @@ 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 @@ -22,8 +24,24 @@ class UserController( return ResponseEntity.ok(userMapper.toUserDto(user)) } + override fun updateUser(id: String, userDto: UserDto?): ResponseEntity { + 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 { userService.deleteUser(id) return ResponseEntity.noContent().build() } -} \ No newline at end of file +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserMapper.kt index 8a1f9e6..945c424 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserMapper.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserMapper.kt @@ -4,22 +4,31 @@ import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto import org.springframework.stereotype.Component @Component -class UserMapper() { +class UserMapper( + private val roleConverter: UserRoleConverter +) { fun toUserDto(user: User): UserDto { + val organizationRolesDto = roleConverter.convertToMap(user.organizationRoles) + return UserDto( id = user.id, name = user.name, status = user.status, - role = user.role + organizationRoles = organizationRolesDto ) } fun toUser(userDto: UserDto): User { - return User( + val user = User( id = userDto.id, name = userDto.name, - status = userDto.status, - role = userDto.role + status = userDto.status ) + + userDto.organizationRoles.forEach { (orgId, roles) -> + roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles) + } + + return user } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserOrganizationRole.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserOrganizationRole.kt new file mode 100644 index 0000000..c4df765 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserOrganizationRole.kt @@ -0,0 +1,13 @@ +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 +) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserRoleConverter.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserRoleConverter.kt new file mode 100644 index 0000000..688291b --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserRoleConverter.kt @@ -0,0 +1,42 @@ +package com.betriebsratkanzlei.legalconsenthub.user + +import com.betriebsratkanzlei.legalconsenthub_api.model.UserRole +import org.springframework.stereotype.Component + +@Component +object UserRoleConverter { + + fun getRolesForOrganization(organizationRoles: Set, organizationId: String): List { + return organizationRoles + .filter { it.organizationId == organizationId } + .mapNotNull { orgRole -> + try { + UserRole.valueOf(orgRole.role) + } catch (e: IllegalArgumentException) { + null + } + } + } + + fun setRolesForOrganization(organizationRoles: MutableSet, organizationId: String, roles: List) { + organizationRoles.removeIf { it.organizationId == organizationId } + roles.forEach { role -> + organizationRoles.add(UserOrganizationRole(organizationId, role.name)) + } + } + + fun convertToMap(organizationRoles: Set): Map> { + return organizationRoles + .groupBy { it.organizationId } + .mapValues { (_, roles) -> + roles.mapNotNull { orgRole -> + try { + UserRole.valueOf(orgRole.role) + } catch (e: IllegalArgumentException) { + null + } + } + } + .filterValues { it.isNotEmpty() } + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserService.kt index ea5ef2f..f1f4a3f 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/UserService.kt @@ -4,13 +4,16 @@ 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 @Service class UserService( - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val roleConverter: UserRoleConverter ) { fun getCurrentUser(): User { @@ -29,9 +32,13 @@ class UserService( val user = User( id = createUserDto.id, name = createUserDto.name, - status = createUserDto.status ?: UserStatus.ACTIVE, - role = createUserDto.role + status = createUserDto.status ) + + createUserDto.organizationRoles?.forEach { (orgId, roles) -> + roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles) + } + return userRepository.save(user) } @@ -40,6 +47,44 @@ class UserService( .orElseThrow { UserNotFoundException(userId) } } + @Transactional + fun updateUser(userId: String, userDto: UserDto): User { + val user = userRepository.findById(userId) + .orElseThrow { UserNotFoundException(userId) } + + user.name = userDto.name + user.status = userDto.status + + user.organizationRoles.clear() + userDto.organizationRoles.forEach { (orgId, roles) -> + roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles) + } + + return userRepository.save(user) + } + + @Transactional + fun updateUserFromJwt(userId: String, jwtOrganizationId: String?, jwtRoles: List?): 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) } diff --git a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql index c313639..b711171 100644 --- a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql +++ b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql @@ -4,7 +4,6 @@ create table app_user modified_at timestamp(6) not null, id varchar(255) not null, name varchar(255) not null, - role varchar(255), status varchar(255) not null check (status in ('INVITED', 'ACTIVE', 'BLOCKED', 'SUSPENDED_SUBSCRIPTION')), primary key (id) ); @@ -66,18 +65,27 @@ create table form_element_section create table notification ( - is_read boolean not null, - created_at timestamp(6) not null, - id uuid not null, - click_target varchar(255) not null, - message TEXT not null, - recipient_id varchar(255), - target_group varchar(255) not null, - title varchar(255) not null, - type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')), + is_read boolean not null, + created_at timestamp(6) not null, + id uuid not null, + click_target varchar(255) not null, + message TEXT not null, + organization_id varchar(255) not null, + recipient_id varchar(255), + role varchar(255) not null, + title varchar(255) not null, + type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')), primary key (id) ); +create table user_organization_roles +( + organization_id varchar(255) not null, + role varchar(255) not null, + user_id varchar(255) not null, + primary key (organization_id, role, user_id) +); + alter table if exists application_form add constraint FKhtad5onoy2jknhtyfmx6cvvey foreign key (created_by_id) @@ -122,3 +130,8 @@ alter table if exists notification add constraint FKeg1j4hnp0y4lbm0y35hgr4e8r foreign key (recipient_id) references app_user; + +alter table if exists user_organization_roles + add constraint FKhgmm93qre3up6hy63wcef3yqk + foreign key (user_id) + references app_user diff --git a/legalconsenthub/composables/useAuth.ts b/legalconsenthub/composables/useAuth.ts index 8a15d10..f228a12 100644 --- a/legalconsenthub/composables/useAuth.ts +++ b/legalconsenthub/composables/useAuth.ts @@ -93,7 +93,7 @@ export function useAuth() { redirectGuestTo: '/login' }) - async function fetchSession() { + async function fetchSession(targetPath?: string) { if (sessionFetching.value) { console.log('already fetching session') return @@ -109,7 +109,7 @@ export function useAuth() { sessionFetching.value = false // Only fetch JWT and organizations if we have a session and not on public routes - if (session.value && !isPublicRoute()) { + if (session.value && !isPublicPath(targetPath)) { await fetchJwtAndOrganizations() } @@ -160,10 +160,10 @@ export function useAuth() { }) } - function isPublicRoute(routeToCheck?: RouteLocationNormalizedLoaded) { - const finalRoute = routeToCheck ?? route + function isPublicPath(path?: string) { + const finalPath = path ?? route.path const publicRoutes = ['/login', '/signup', '/accept-invitation'] - return publicRoutes.some((path) => finalRoute.path.startsWith(path)) + return publicRoutes.some((path) => finalPath.startsWith(path)) } async function signOut({ redirectTo }: { redirectTo?: RouteLocationRaw } = {}) { @@ -195,7 +195,7 @@ export function useAuth() { fetchSession, client, jwt, - isPublicRoute, + isPublicPath, activeMember } } diff --git a/legalconsenthub/composables/user/useUser.ts b/legalconsenthub/composables/user/useUser.ts index f52ea3a..daf9299 100644 --- a/legalconsenthub/composables/user/useUser.ts +++ b/legalconsenthub/composables/user/useUser.ts @@ -37,6 +37,19 @@ export function useUser() { } } + async function updateUser(id: string, userDto?: UserDto): Promise { + 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 { try { return await userApi.deleteUser(id) @@ -53,6 +66,7 @@ export function useUser() { return { createUser, getUserById, + updateUser, deleteUser } } \ No newline at end of file diff --git a/legalconsenthub/composables/user/useUserApi.ts b/legalconsenthub/composables/user/useUserApi.ts index c8550c1..48d84f9 100644 --- a/legalconsenthub/composables/user/useUserApi.ts +++ b/legalconsenthub/composables/user/useUserApi.ts @@ -1,9 +1,4 @@ -import { - UserApi, - Configuration, - type CreateUserDto, - type UserDto -} from '~/.api-client' +import { UserApi, Configuration, type CreateUserDto, type UserDto } from '~/.api-client' import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo' export function useUserApi() { @@ -15,25 +10,32 @@ export function useUserApi() { cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath) ) - const userApiClient = new UserApi( - new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } }) + // 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 { - return userApiClient.createUser({ createUserDto }) + return userApiClient.value.createUser({ createUserDto }) } async function getUserById(id: string): Promise { - return userApiClient.getUserById({ id }) + return userApiClient.value.getUserById({ id }) + } + + async function updateUser(id: string, userDto?: UserDto): Promise { + return userApiClient.value.updateUser({ id, userDto }) } async function deleteUser(id: string): Promise { - return userApiClient.deleteUser({ id }) + return userApiClient.value.deleteUser({ id }) } return { createUser, getUserById, + updateUser, deleteUser } -} \ No newline at end of file +} diff --git a/legalconsenthub/middleware/auth.global.ts b/legalconsenthub/middleware/auth.global.ts index f760ecb..dfea8e9 100644 --- a/legalconsenthub/middleware/auth.global.ts +++ b/legalconsenthub/middleware/auth.global.ts @@ -38,7 +38,7 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => console.log('[1] Auth middleware disabled for this route:', to.path) return } - const { loggedIn, options, fetchSession, isPublicRoute } = useAuth() + const { loggedIn, options, fetchSession, isPublicPath } = useAuth() const { only, redirectUserTo, redirectGuestTo } = defu(to.meta?.auth, options) // 2. If guest mode, redirect if authenticated @@ -55,7 +55,7 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => if (import.meta.client) { console.log('[3] Client-side navigation, fetching session') try { - await fetchSession() + await fetchSession(to.path) } catch (e) { console.error(e) } @@ -63,7 +63,7 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => // 4. If not authenticated, redirect to home or guest route if (!loggedIn.value) { - if (isPublicRoute(to)) { + if (isPublicPath(to.path)) { console.log('[4] Not authenticated, but route is public:', to.path) // Continue navigating to the public route return diff --git a/legalconsenthub/package.json b/legalconsenthub/package.json index 57ad38a..1646f37 100644 --- a/legalconsenthub/package.json +++ b/legalconsenthub/package.json @@ -24,7 +24,7 @@ "@nuxtjs/i18n": "10.0.3", "@pinia/nuxt": "0.10.1", "@vueuse/core": "^13.6.0", - "better-auth": "1.3.4", + "better-auth": "1.3.9", "better-sqlite3": "11.8.1", "nuxt": "3.16.1", "pinia": "3.0.1", diff --git a/legalconsenthub/pnpm-lock.yaml b/legalconsenthub/pnpm-lock.yaml index aa8b0a0..3d66571 100644 --- a/legalconsenthub/pnpm-lock.yaml +++ b/legalconsenthub/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@nuxt/ui-pro': specifier: 3.1.1 - version: 3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.0.10) + version: 3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.1.8) '@nuxtjs/i18n': specifier: 10.0.3 version: 10.0.3(@vue/compiler-dom@3.5.18)(db0@0.3.1(better-sqlite3@11.8.1))(eslint@9.20.1(jiti@2.4.2))(ioredis@5.6.0)(magicast@0.3.5)(rollup@4.38.0)(vue@3.5.13(typescript@5.7.3)) @@ -21,8 +21,8 @@ importers: specifier: ^13.6.0 version: 13.6.0(vue@3.5.13(typescript@5.7.3)) better-auth: - specifier: 1.3.4 - version: 1.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: 1.3.9 + version: 1.3.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) better-sqlite3: specifier: 11.8.1 version: 11.8.1 @@ -243,8 +243,8 @@ packages: resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} - '@better-auth/utils@0.2.5': - resolution: {integrity: sha512-uI2+/8h/zVsH8RrYdG8eUErbuGBk16rZKQfz8CjxQOyCE6v7BqFYEbFwvOkvl1KbUdxhqOnXp78+uE5h8qVEgQ==} + '@better-auth/utils@0.2.6': + resolution: {integrity: sha512-3y/vaL5Ox33dBwgJ6ub3OPkVqr6B5xL2kgxNHG8eHZuryLyG/4JSPGqjbdRSgjuy9kALUZYDFl+ORIAxlWMSuA==} '@better-fetch/fetch@1.1.18': resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} @@ -1031,12 +1031,13 @@ packages: resolution: {integrity: sha512-z6okREyK8in0486a22Oro0k+YsuyEjDXJt46FpgeOgXqKJ9ElM8QPll0iuLBkpbH33ENiNbIPLd1cuClRQnhiw==} engines: {node: '>=18.0.0'} - '@noble/ciphers@0.6.0': - resolution: {integrity: sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ==} + '@noble/ciphers@2.0.0': + resolution: {integrity: sha512-j/l6jpnpaIBM87cAYPJzi/6TgqmBv9spkqPyCXvRYsu5uxqh6tPJZDnD85yo8VWqzTuTQPgfv7NgT63u7kbwAQ==} + engines: {node: '>= 20.19.0'} - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.0.0': + resolution: {integrity: sha512-h8VUBlE8R42+XIDO229cgisD287im3kdY6nbNZJFjc6ZvKIXPYXe6Vc/t+kyjFdMFyt5JpapzTsEg8n63w5/lw==} + engines: {node: '>= 20.19.0'} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1694,11 +1695,11 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} - '@simplewebauthn/browser@13.1.0': - resolution: {integrity: sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==} + '@simplewebauthn/browser@13.1.2': + resolution: {integrity: sha512-aZnW0KawAM83fSBUgglP5WofbrLbLyr7CoPqYr66Eppm7zO86YX6rrCjRB3hQKPrL7ATvY4FVXlykZ6w6FwYYw==} - '@simplewebauthn/server@13.1.1': - resolution: {integrity: sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==} + '@simplewebauthn/server@13.1.2': + resolution: {integrity: sha512-VwoDfvLXSCaRiD+xCIuyslU0HLxVggeE5BL06+GbsP2l1fGf5op8e0c3ZtKoi+vSg1q4ikjtAghC23ze2Q3H9g==} engines: {node: '>=20.0.0'} '@sindresorhus/is@7.0.1': @@ -2357,19 +2358,22 @@ packages: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} - better-auth@1.3.4: - resolution: {integrity: sha512-JbZYam6Cs3Eu5CSoMK120zSshfaKvrCftSo/+v7524H1RvhryQ7UtMbzagBcXj0Digjj8hZtVkkR4tTZD/wK2g==} + better-auth@1.3.9: + resolution: {integrity: sha512-Ty6BHzuShlqSs7I4RMlBRQ3duOWNB7WWriIu2FJVGjQAOtTVvamzFCR4/j5ROFLoNkpvNTRF7BJozsrMICL1gw==} peerDependencies: + '@lynx-js/react': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: + '@lynx-js/react': + optional: true react: optional: true react-dom: optional: true - better-call@1.0.12: - resolution: {integrity: sha512-ssq5OfB9Ungv2M1WVrRnMBomB0qz1VKuhkY2WxjHaLtlsHoSe9EPolj1xf7xf8LY9o3vfk3Rx6rCWI4oVHeBRg==} + better-call@1.0.18: + resolution: {integrity: sha512-Ojyck3P3fs/egBmCW50tvfbCJorNV5KphfPOKrkCxPfOr8Brth1ruDtAJuhHVHEUiWrXv+vpEgWQk7m7FzhbbQ==} better-sqlite3@11.8.1: resolution: {integrity: sha512-9BxNaBkblMjhJW8sMRZxnxVTRgbRmssZW0Oxc1MPBTfiR+WW21e2Mk4qu8CzrcZb1LwPCnFsfDEzq+SNcBU8eg==} @@ -3688,8 +3692,8 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true - jose@5.10.0: - resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.1.0: + resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3766,8 +3770,8 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - kysely@0.28.3: - resolution: {integrity: sha512-svKnkSH72APRdjfVCCOknxaC9Eb3nA2StHG9d5/sKOqRvHRp2Dtf1XwDvc92b4B5v6LV+EAGWXQbZ5jMOvHaDw==} + kysely@0.28.6: + resolution: {integrity: sha512-QQlpW/Db5yhhY9+c1jiCBCUCJqZoWLw6c1rE+H+FqGujMuIAxerCSdQNvyP3zyhQUO913J9Ank1NsQEb5a15mA==} engines: {node: '>=20.0.0'} launch-editor@2.10.0: @@ -5746,33 +5750,33 @@ packages: peerDependencies: zod: ^3.24.1 - zod@4.0.10: - resolution: {integrity: sha512-3vB+UU3/VmLL2lvwcY/4RV2i9z/YU0DTV/tDuYjrwmx5WeJ7hwy+rGEEx8glHp6Yxw7ibRbKSaIFBgReRPe5KA==} + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} snapshots: - '@ai-sdk/provider-utils@2.2.8(zod@4.0.10)': + '@ai-sdk/provider-utils@2.2.8(zod@4.1.8)': dependencies: '@ai-sdk/provider': 1.1.3 nanoid: 3.3.11 secure-json-parse: 2.7.0 - zod: 4.0.10 + zod: 4.1.8 '@ai-sdk/provider@1.1.3': dependencies: json-schema: 0.4.0 - '@ai-sdk/ui-utils@1.2.11(zod@4.0.10)': + '@ai-sdk/ui-utils@1.2.11(zod@4.1.8)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@4.0.10) - zod: 4.0.10 - zod-to-json-schema: 3.24.5(zod@4.0.10) + '@ai-sdk/provider-utils': 2.2.8(zod@4.1.8) + zod: 4.1.8 + zod-to-json-schema: 3.24.5(zod@4.1.8) - '@ai-sdk/vue@1.2.12(vue@3.5.13(typescript@5.7.3))(zod@4.0.10)': + '@ai-sdk/vue@1.2.12(vue@3.5.13(typescript@5.7.3))(zod@4.1.8)': dependencies: - '@ai-sdk/provider-utils': 2.2.8(zod@4.0.10) - '@ai-sdk/ui-utils': 1.2.11(zod@4.0.10) + '@ai-sdk/provider-utils': 2.2.8(zod@4.1.8) + '@ai-sdk/ui-utils': 1.2.11(zod@4.1.8) swrv: 1.1.0(vue@3.5.13(typescript@5.7.3)) optionalDependencies: vue: 3.5.13(typescript@5.7.3) @@ -5981,9 +5985,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@better-auth/utils@0.2.5': + '@better-auth/utils@0.2.6': dependencies: - typescript: 5.8.3 uncrypto: 0.1.3 '@better-fetch/fetch@1.1.18': {} @@ -6613,9 +6616,9 @@ snapshots: '@netlify/serverless-functions-api@1.36.0': {} - '@noble/ciphers@0.6.0': {} + '@noble/ciphers@2.0.0': {} - '@noble/hashes@1.8.0': {} + '@noble/hashes@2.0.0': {} '@nodelib/fs.scandir@2.1.5': dependencies: @@ -6983,12 +6986,12 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/ui-pro@3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.0.10)': + '@nuxt/ui-pro@3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.1.8)': dependencies: - '@ai-sdk/vue': 1.2.12(vue@3.5.13(typescript@5.7.3))(zod@4.0.10) + '@ai-sdk/vue': 1.2.12(vue@3.5.13(typescript@5.7.3))(zod@4.1.8) '@nuxt/kit': 3.17.2(magicast@0.3.5) '@nuxt/schema': 3.17.2 - '@nuxt/ui': 3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.0.10) + '@nuxt/ui': 3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.1.8) '@standard-schema/spec': 1.0.0 '@vueuse/core': 13.6.0(vue@3.5.13(typescript@5.7.3)) consola: 3.4.2 @@ -7006,7 +7009,7 @@ snapshots: unplugin-auto-import: 19.1.2(@nuxt/kit@3.17.2(magicast@0.3.5))(@vueuse/core@13.6.0(vue@3.5.13(typescript@5.7.3))) unplugin-vue-components: 28.5.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.2(magicast@0.3.5))(vue@3.5.13(typescript@5.7.3)) optionalDependencies: - zod: 4.0.10 + zod: 4.1.8 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -7047,7 +7050,7 @@ snapshots: - vue - vue-router - '@nuxt/ui@3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.0.10)': + '@nuxt/ui@3.1.1(@babel/parser@7.28.0)(axios@1.7.9)(db0@0.3.1(better-sqlite3@11.8.1))(embla-carousel@8.6.0)(ioredis@5.6.0)(magicast@0.3.5)(typescript@5.7.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))(zod@4.1.8)': dependencies: '@iconify/vue': 4.3.0(vue@3.5.13(typescript@5.7.3)) '@internationalized/date': 3.8.0 @@ -7094,7 +7097,7 @@ snapshots: vue-component-type-helpers: 2.2.10 optionalDependencies: vue-router: 4.5.0(vue@3.5.13(typescript@5.7.3)) - zod: 4.0.10 + zod: 4.1.8 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -7652,9 +7655,9 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 - '@simplewebauthn/browser@13.1.0': {} + '@simplewebauthn/browser@13.1.2': {} - '@simplewebauthn/server@13.1.1': + '@simplewebauthn/server@13.1.2': dependencies: '@hexagon/base64': 1.1.28 '@levischuck/tiny-cbor': 0.2.11 @@ -8395,25 +8398,25 @@ snapshots: basic-ftp@5.0.5: {} - better-auth@1.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + better-auth@1.3.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@better-auth/utils': 0.2.5 + '@better-auth/utils': 0.2.6 '@better-fetch/fetch': 1.1.18 - '@noble/ciphers': 0.6.0 - '@noble/hashes': 1.8.0 - '@simplewebauthn/browser': 13.1.0 - '@simplewebauthn/server': 13.1.1 - better-call: 1.0.12 + '@noble/ciphers': 2.0.0 + '@noble/hashes': 2.0.0 + '@simplewebauthn/browser': 13.1.2 + '@simplewebauthn/server': 13.1.2 + better-call: 1.0.18 defu: 6.1.4 - jose: 5.10.0 - kysely: 0.28.3 + jose: 6.1.0 + kysely: 0.28.6 nanostores: 0.11.4 - zod: 4.0.10 + zod: 4.1.8 optionalDependencies: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - better-call@1.0.12: + better-call@1.0.18: dependencies: '@better-fetch/fetch': 1.1.18 rou3: 0.5.1 @@ -9875,7 +9878,7 @@ snapshots: jiti@2.4.2: {} - jose@5.10.0: {} + jose@6.1.0: {} js-tokens@4.0.0: {} @@ -9935,7 +9938,7 @@ snapshots: kolorist@1.8.0: {} - kysely@0.28.3: {} + kysely@0.28.6: {} launch-editor@2.10.0: dependencies: @@ -12112,8 +12115,8 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 - zod-to-json-schema@3.24.5(zod@4.0.10): + zod-to-json-schema@3.24.5(zod@4.1.8): dependencies: - zod: 4.0.10 + zod: 4.1.8 - zod@4.0.10: {} + zod@4.1.8: {} diff --git a/legalconsenthub/server/utils/auth.ts b/legalconsenthub/server/utils/auth.ts index 86fbd49..eabb6b6 100644 --- a/legalconsenthub/server/utils/auth.ts +++ b/legalconsenthub/server/utils/auth.ts @@ -8,20 +8,50 @@ import { worksCouncilMemberRole, employeeRole, adminRole, + ownerRole, ROLES, type LegalRole } from './permissions' +const db = new Database('./sqlite.db') + export const auth = betterAuth({ - database: new Database('./sqlite.db'), + database: db, onAPIError: { throw: true }, - emailAndPassword: { enabled: true, autoSignIn: false }, + emailAndPassword: { enabled: true, autoSignIn: false, minPasswordLength: 1 }, trustedOrigins: ['http://localhost:3001'], plugins: [ jwt({ jwt: { issuer: 'http://192.168.178.114:3001', - expirationTime: '1yr' + 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: { @@ -37,11 +67,10 @@ export const auth = betterAuth({ [ROLES.EMPLOYER]: employerRole, [ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole, [ROLES.EMPLOYEE]: employeeRole, - [ROLES.ADMIN]: adminRole + [ROLES.ADMIN]: adminRole, + [ROLES.OWNER]: ownerRole }, - - // Creator gets admin role by default - creatorRole: ROLES.ADMIN, + creatorRole: ROLES.ADMIN, // OWNER fixen here! async sendInvitationEmail(data) { console.log('Sending invitation email', data) @@ -51,7 +80,8 @@ export const auth = betterAuth({ [ROLES.EMPLOYER]: 'Arbeitgeber', [ROLES.EMPLOYEE]: 'Arbeitnehmer', [ROLES.WORKS_COUNCIL_MEMBER]: 'Betriebsrat', - [ROLES.ADMIN]: 'Administrator' + [ROLES.ADMIN]: 'Administrator', + [ROLES.OWNER]: 'Eigentümer' } const roleDisplayName = roleDisplayNames[data.role as LegalRole] || data.role @@ -70,7 +100,7 @@ export const auth = betterAuth({ }) if (result.error) { - throw new Error(`Email sending failed: ${result.error?.statusCode} ${result.error?.error}`) + throw new Error(`Email sending failed: ${result.error.message || result.error.name || 'Unknown error'}`) } console.log('Email invite link:', inviteLink) diff --git a/legalconsenthub/stores/useOrganizationStore.ts b/legalconsenthub/stores/useOrganizationStore.ts index e8df968..00895ac 100644 --- a/legalconsenthub/stores/useOrganizationStore.ts +++ b/legalconsenthub/stores/useOrganizationStore.ts @@ -91,12 +91,33 @@ export const useOrganizationStore = defineStore('Organization', () => { try { await organizationApi.acceptInvitation(invitationId) await navigateTo('/') + await syncUserRoleToBackend() } catch (e) { toast.add({ title: 'Error accepting invitation', color: 'error' }) console.error('Error accepting invitation:', e) } } + async function syncUserRoleToBackend() { + try { + const { updateUser } = useUser() + const { user } = useAuth() + + if (!user.value?.id) { + console.warn('No user ID available for role sync') + return + } + + // Call updateUser without userDto to trigger JWT-based sync + await updateUser(user.value.id) + + console.log('Successfully synced user role to backend from JWT') + } catch (error) { + console.error('Failed to sync user role to backend from JWT:', error) + // Don't throw - role sync failure shouldn't prevent invitation acceptance + } + } + async function cancelSentInvitation(invitationId: string) { try { await organizationApi.cancelSentInvitation(invitationId)