feat(fullstack): Set user roles per orga, scope notification to orga and role, add orga and role to JWT

This commit is contained in:
2025-09-15 19:23:06 +02:00
parent 83f1fa71b6
commit e3643d8318
25 changed files with 575 additions and 287 deletions

View File

@@ -394,6 +394,35 @@ paths:
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503": "503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable" $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: delete:
summary: Delete a user summary: Delete a user
operationId: deleteUser operationId: deleteUser
@@ -1049,6 +1078,7 @@ components:
- id - id
- name - name
- status - status
- organizationRoles
properties: properties:
id: id:
type: string type: string
@@ -1056,8 +1086,13 @@ components:
type: string type: string
status: status:
$ref: "#/components/schemas/UserStatus" $ref: "#/components/schemas/UserStatus"
role: organizationRoles:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/UserRole" $ref: "#/components/schemas/UserRole"
description: "Map of organization IDs to arrays of user roles in those organizations"
CreateUserDto: CreateUserDto:
type: object type: object
@@ -1065,7 +1100,6 @@ components:
- id - id
- name - name
- status - status
- role
properties: properties:
id: id:
type: string type: string
@@ -1073,8 +1107,13 @@ components:
type: string type: string
status: status:
$ref: "#/components/schemas/UserStatus" $ref: "#/components/schemas/UserStatus"
role: organizationRoles:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/UserRole" $ref: "#/components/schemas/UserRole"
description: "Map of organization IDs to arrays of user roles in those organizations"
UserStatus: UserStatus:
type: string type: string
@@ -1176,9 +1215,10 @@ components:
- message - message
- clickTarget - clickTarget
- isRead - isRead
- targetGroup - role
- type - type
- createdAt - createdAt
- organizationId
properties: properties:
id: id:
type: string type: string
@@ -1195,10 +1235,12 @@ components:
nullable: true nullable: true
allOf: allOf:
- $ref: "#/components/schemas/UserDto" - $ref: "#/components/schemas/UserDto"
targetGroup: role:
type: string type: string
type: type:
$ref: "#/components/schemas/NotificationType" $ref: "#/components/schemas/NotificationType"
organizationId:
type: string
createdAt: createdAt:
type: string type: string
format: date-time format: date-time
@@ -1209,8 +1251,9 @@ components:
- title - title
- message - message
- clickTarget - clickTarget
- targetGroup - role
- type - type
- organizationId
properties: properties:
title: title:
type: string type: string
@@ -1222,10 +1265,12 @@ components:
nullable: true nullable: true
allOf: allOf:
- $ref: "#/components/schemas/UserDto" - $ref: "#/components/schemas/UserDto"
targetGroup: role:
type: string type: string
type: type:
$ref: "#/components/schemas/NotificationType" $ref: "#/components/schemas/NotificationType"
organizationId:
type: string
PagedNotificationDto: PagedNotificationDto:
type: object type: object

View File

@@ -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 message = "Ein neuer Mitbestimmungsantrag '${applicationForm.name}' wurde von ${applicationForm.createdBy.name} eingereicht und wartet auf Ihre Bearbeitung."
val clickTarget = "/application-forms/${applicationForm.id}/0" val clickTarget = "/application-forms/${applicationForm.id}/0"
// Create notification for admin users // Create separate notification for each role that should be notified
notificationService.createNotificationForUser( val rolesToNotify = listOf("admin", "works_council_member", "employer", "employee")
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
targetGroup = "admin",
type = NotificationType.INFO
)
// Create notification for works council members rolesToNotify.forEach { role ->
notificationService.createNotificationForUser( notificationService.createNotification(
title = title, title = title,
message = message, message = message,
clickTarget = clickTarget, clickTarget = clickTarget,
recipient = null, recipient = null,
targetGroup = "works_council_member", role = role,
type = NotificationType.INFO organizationId = applicationForm.organizationId,
)
// 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 type = NotificationType.INFO
) )
} }
}
} }

View File

@@ -1,5 +1,6 @@
package com.betriebsratkanzlei.legalconsenthub.config package com.betriebsratkanzlei.legalconsenthub.config
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtAuthenticationConverter
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order import org.springframework.core.annotation.Order
@@ -20,14 +21,12 @@ class SecurityConfig {
@Order(1) @Order(1)
fun publicApiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { fun publicApiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http { http {
securityMatcher("/swagger-ui/**", "/v3/**", "/actuator/**", "/users") securityMatcher("/swagger-ui/**", "/v3/**", "/actuator/**")
csrf { disable() } csrf { disable() }
authorizeHttpRequests { authorizeHttpRequests {
authorize("/swagger-ui/**", permitAll) authorize("/swagger-ui/**", permitAll)
authorize("/v3/**", permitAll) authorize("/v3/**", permitAll)
authorize("/actuator/**", permitAll) authorize("/actuator/**", permitAll)
// For user registration
authorize(HttpMethod.POST, "/users", permitAll)
authorize(anyRequest, denyAll) authorize(anyRequest, denyAll)
} }
} }
@@ -43,6 +42,8 @@ class SecurityConfig {
http { http {
csrf { disable() } csrf { disable() }
authorizeHttpRequests { authorizeHttpRequests {
// Allow user registration without authentication
authorize(HttpMethod.POST, "/users", permitAll)
authorize(anyRequest, authenticated) authorize(anyRequest, authenticated)
} }
oauth2ResourceServer { oauth2ResourceServer {

View File

@@ -40,12 +40,15 @@ class Notification(
var recipient: User?, var recipient: User?,
@Column(nullable = false) @Column(nullable = false)
var targetGroup: String = "", var role: String = "",
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false)
var type: NotificationType = NotificationType.INFO, var type: NotificationType = NotificationType.INFO,
@Column(nullable = false)
var organizationId: String = "",
@CreatedDate @CreatedDate
@Column(nullable = false) @Column(nullable = false)
var createdAt: LocalDateTime? = null var createdAt: LocalDateTime? = null

View File

@@ -1,7 +1,6 @@
package com.betriebsratkanzlei.legalconsenthub.notification package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
@@ -15,30 +14,20 @@ import java.util.UUID
class NotificationController( class NotificationController(
private val notificationService: NotificationService, private val notificationService: NotificationService,
private val notificationMapper: NotificationMapper, private val notificationMapper: NotificationMapper,
private val pagedNotificationMapper: PagedNotificationMapper, private val pagedNotificationMapper: PagedNotificationMapper
private val userService: UserService
) : NotificationApi { ) : NotificationApi {
override fun getNotifications(page: Int, size: Int): ResponseEntity<PagedNotificationDto> { override fun getNotifications(page: Int, size: Int): ResponseEntity<PagedNotificationDto> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found") 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 = notificationService.getNotifications(
val notifications = if (user.role != null) {
notificationService.getNotificationsForUserAndGroup(
recipientId = recipientId, recipientId = recipientId,
userRole = user.role!!.value, organizationId = organizationId,
page = page, page = page,
size = size size = size
) )
} else {
notificationService.getNotificationsForUser(
recipientId = recipientId,
page = page,
size = size
)
}
return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications)) return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications))
} }
@@ -46,29 +35,25 @@ class NotificationController(
override fun getUnreadNotifications(): ResponseEntity<List<NotificationDto>> { override fun getUnreadNotifications(): ResponseEntity<List<NotificationDto>> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found") 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 = notificationService.getUnreadNotifications(
recipientId = recipientId,
val notifications = if (user.role != null) { organizationId = organizationId
notificationService.getUnreadNotificationsForUserAndGroup(recipientId, user.role!!.value) )
} else {
notificationService.getUnreadNotificationsForUser(recipientId)
}
return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) }) return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) })
} }
override fun getUnreadNotificationCount(): ResponseEntity<Long> { override fun getUnreadNotificationCount(): ResponseEntity<kotlin.Long> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found") 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 = notificationService.getUnreadNotificationCount(
recipientId = recipientId,
val count = if (user.role != null) { organizationId = organizationId
notificationService.getUnreadNotificationCountForUserAndGroup(recipientId, user.role!!.value) )
} else {
notificationService.getUnreadNotificationCount(recipientId)
}
return ResponseEntity.ok(count) return ResponseEntity.ok(count)
} }
@@ -76,22 +61,18 @@ class NotificationController(
override fun markAllNotificationsAsRead(): ResponseEntity<Unit> { override fun markAllNotificationsAsRead(): ResponseEntity<Unit> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found") 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) notificationService.markAllAsRead(
recipientId = recipientId,
if (user.role != null) { organizationId = organizationId
notificationService.markAllAsReadForUserAndGroup(recipientId, user.role!!.value) )
} else {
notificationService.markAllAsRead(recipientId)
}
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
override fun markNotificationAsRead(id: UUID): ResponseEntity<NotificationDto> { override fun markNotificationAsRead(id: UUID): ResponseEntity<NotificationDto> {
val notification = notificationService.markAsRead(id) val notification = notificationService.markNotificationAsRead(id)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(notificationMapper.toNotificationDto(notification)) return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
} }

View File

@@ -18,8 +18,9 @@ class NotificationMapper(
clickTarget = notification.clickTarget, clickTarget = notification.clickTarget,
isRead = notification.isRead, isRead = notification.isRead,
recipient = notification.recipient?.let { userMapper.toUserDto(it) }, recipient = notification.recipient?.let { userMapper.toUserDto(it) },
targetGroup = notification.targetGroup, role = notification.role,
type = notification.type, type = notification.type,
organizationId = notification.organizationId,
createdAt = notification.createdAt!! createdAt = notification.createdAt!!
) )
} }
@@ -31,8 +32,9 @@ class NotificationMapper(
clickTarget = createNotificationDto.clickTarget, clickTarget = createNotificationDto.clickTarget,
isRead = false, isRead = false,
recipient = createNotificationDto.recipient?.let { userMapper.toUser(it) }, recipient = createNotificationDto.recipient?.let { userMapper.toUser(it) },
targetGroup = createNotificationDto.targetGroup, role = createNotificationDto.role,
type = createNotificationDto.type type = createNotificationDto.type,
organizationId = createNotificationDto.organizationId
) )
} }
} }

View File

@@ -13,35 +13,64 @@ import java.util.UUID
interface NotificationRepository : JpaRepository<Notification, UUID> { interface NotificationRepository : JpaRepository<Notification, UUID> {
fun findByRecipientIdOrderByCreatedAtDesc(recipientId: String?, pageable: Pageable): Page<Notification> fun findByRecipientIdOrderByCreatedAtDesc(recipientId: String?, pageable: Pageable): Page<Notification>
fun findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId: String?): List<Notification> fun findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId: String?): List<Notification>
fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long
fun findByRecipientIsNullOrderByCreatedAtDesc(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 = ''))
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>
fun findByRecipientIsNullAndIsReadFalseOrderByCreatedAtDesc(): List<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 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") @Query("""
fun findByRecipientIdOrTargetGroupOrderByCreatedAtDesc(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String, pageable: Pageable): Page<Notification> 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
@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") @Modifying
fun findByRecipientIdOrTargetGroupAndIsReadFalseOrderByCreatedAtDesc(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String): List<Notification> @Query("""
UPDATE Notification n SET n.isRead = true WHERE
@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") (n.recipient.id = :recipientId) OR
fun countByRecipientIdOrTargetGroupAndIsReadFalse(@Param("recipientId") recipientId: String, @Param("targetGroup") targetGroup: String): Long (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 @Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.id = :recipientId") @Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.id = :recipientId")
fun markAllAsReadByRecipientId(@Param("recipientId") recipientId: String) 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
} }

View File

@@ -1,6 +1,8 @@
package com.betriebsratkanzlei.legalconsenthub.notification package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.user.User 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.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
@@ -12,7 +14,9 @@ import java.util.UUID
@Service @Service
class NotificationService( class NotificationService(
private val notificationRepository: NotificationRepository, 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 { fun createNotification(createNotificationDto: CreateNotificationDto): Notification {
@@ -20,12 +24,13 @@ class NotificationService(
return notificationRepository.save(notification) return notificationRepository.save(notification)
} }
fun createNotificationForUser( fun createNotification(
title: String, title: String,
message: String, message: String,
clickTarget: String, clickTarget: String,
recipient: User?, recipient: User?,
targetGroup: String, role: String,
organizationId: String,
type: NotificationType = NotificationType.INFO type: NotificationType = NotificationType.INFO
): Notification { ): Notification {
val notification = Notification( val notification = Notification(
@@ -33,55 +38,90 @@ class NotificationService(
message = message, message = message,
clickTarget = clickTarget, clickTarget = clickTarget,
recipient = recipient, recipient = recipient,
targetGroup = targetGroup, role = role,
type = type type = type,
organizationId = organizationId
) )
return notificationRepository.save(notification) return notificationRepository.save(notification)
} }
fun getNotificationsForUser(recipientId: String, page: Int = 0, size: Int = 20): Page<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) val pageable = PageRequest.of(page, size)
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable) return if (userRoles.isNotEmpty()) {
notificationRepository.findUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs, pageable)
} else {
notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable)
}
} }
fun getNotificationsForUserAndGroup(recipientId: String, userRole: String, page: Int = 0, size: Int = 20): Page<Notification> { fun getUnreadNotifications(
val pageable = PageRequest.of(page, size) recipientId: String,
return notificationRepository.findByRecipientIdOrTargetGroupOrderByCreatedAtDesc(recipientId, userRole, pageable) 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 getUnreadNotificationsForUser(recipientId: String): List<Notification> { fun getUnreadNotificationCount(
return notificationRepository.findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId) recipientId: String,
} organizationId: String
): Long {
val user = userRepository.findById(recipientId)
.orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
fun getUnreadNotificationsForUserAndGroup(recipientId: String, userRole: String): List<Notification> { val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
return notificationRepository.findByRecipientIdOrTargetGroupAndIsReadFalseOrderByCreatedAtDesc(recipientId, userRole) val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
}
fun getUnreadNotificationCount(recipientId: String): Long { return if (userRoles.isNotEmpty()) {
return notificationRepository.countByRecipientIdAndIsReadFalse(recipientId) notificationRepository.countUnreadUserNotifications(recipientId, listOf(organizationId), orgRolePairs)
} else {
notificationRepository.countByRecipientIdAndIsReadFalse(recipientId)
} }
fun getUnreadNotificationCountForUserAndGroup(recipientId: String, userRole: String): Long {
return notificationRepository.countByRecipientIdOrTargetGroupAndIsReadFalse(recipientId, userRole)
} }
@Transactional @Transactional
fun markAllAsRead(recipientId: String) { 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) notificationRepository.markAllAsReadByRecipientId(recipientId)
} }
}
@Transactional @Transactional
fun markAllAsReadForUserAndGroup(recipientId: String, userRole: String) { fun markNotificationAsRead(notificationId: UUID): Notification {
notificationRepository.markAllAsReadByRecipientIdOrTargetGroup(recipientId, userRole) val notification = notificationRepository.findById(notificationId)
} .orElseThrow { IllegalArgumentException("Notification not found with id: $notificationId") }
fun markAsRead(notificationId: UUID): Notification? {
val notification = notificationRepository.findById(notificationId).orElse(null)
return if (notification != null) {
notification.isRead = true notification.isRead = true
notificationRepository.save(notification) return notificationRepository.save(notification)
} else {
null
}
} }
} }

View File

@@ -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.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.GrantedAuthority
@@ -15,7 +13,10 @@ class CustomJwtAuthenticationConverter : Converter<Jwt, AbstractAuthenticationTo
val userId = jwt.getClaimAsString("id") val userId = jwt.getClaimAsString("id")
val username = jwt.getClaimAsString("name") val username = jwt.getClaimAsString("name")
val principal = CustomJwtTokenPrincipal(userId, username) val organizationId = jwt.getClaimAsString("organizationId")
val roles = jwt.getClaimAsStringList("roles") ?: emptyList()
val principal = CustomJwtTokenPrincipal(userId, username, organizationId, roles)
return CustomJwtAuthentication(jwt, principal, authorities) return CustomJwtAuthentication(jwt, principal, authorities)
} }

View File

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

View File

@@ -1,6 +1,5 @@
package com.betriebsratkanzlei.legalconsenthub.user package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub_api.model.UserRole
import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus
import jakarta.persistence.* import jakarta.persistence.*
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
@@ -23,9 +22,9 @@ class User(
@Column(nullable = false) @Column(nullable = false)
var status: UserStatus = UserStatus.ACTIVE, var status: UserStatus = UserStatus.ACTIVE,
@Enumerated(EnumType.STRING) @ElementCollection
@Column(nullable = true) @CollectionTable(name = "user_organization_roles", joinColumns = [JoinColumn(name = "user_id")])
var role: UserRole? = null, var organizationRoles: MutableSet<UserOrganizationRole> = mutableSetOf(),
@CreatedDate @CreatedDate
@Column(nullable = false) @Column(nullable = false)

View File

@@ -1,9 +1,11 @@
package com.betriebsratkanzlei.legalconsenthub.user package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.api.UserApi import com.betriebsratkanzlei.legalconsenthub_api.api.UserApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@RestController @RestController
@@ -22,6 +24,22 @@ class UserController(
return ResponseEntity.ok(userMapper.toUserDto(user)) 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> { override fun deleteUser(id: String): ResponseEntity<Unit> {
userService.deleteUser(id) userService.deleteUser(id)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()

View File

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

View File

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

View File

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

@@ -4,13 +4,16 @@ import com.betriebsratkanzlei.legalconsenthub.error.UserAlreadyExistsException
import com.betriebsratkanzlei.legalconsenthub.error.UserNotFoundException import com.betriebsratkanzlei.legalconsenthub.error.UserNotFoundException
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus
import jakarta.transaction.Transactional
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
class UserService( class UserService(
private val userRepository: UserRepository private val userRepository: UserRepository,
private val roleConverter: UserRoleConverter
) { ) {
fun getCurrentUser(): User { fun getCurrentUser(): User {
@@ -29,9 +32,13 @@ class UserService(
val user = User( val user = User(
id = createUserDto.id, id = createUserDto.id,
name = createUserDto.name, name = createUserDto.name,
status = createUserDto.status ?: UserStatus.ACTIVE, status = createUserDto.status
role = createUserDto.role
) )
createUserDto.organizationRoles?.forEach { (orgId, roles) ->
roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles)
}
return userRepository.save(user) return userRepository.save(user)
} }
@@ -40,6 +47,44 @@ class UserService(
.orElseThrow { UserNotFoundException(userId) } .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<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) { fun deleteUser(userId: String) {
userRepository.deleteById(userId) userRepository.deleteById(userId)
} }

View File

@@ -4,7 +4,6 @@ create table app_user
modified_at timestamp(6) not null, modified_at timestamp(6) not null,
id varchar(255) not null, id varchar(255) not null,
name 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')), status varchar(255) not null check (status in ('INVITED', 'ACTIVE', 'BLOCKED', 'SUSPENDED_SUBSCRIPTION')),
primary key (id) primary key (id)
); );
@@ -71,13 +70,22 @@ create table notification
id uuid not null, id uuid not null,
click_target varchar(255) not null, click_target varchar(255) not null,
message TEXT not null, message TEXT not null,
organization_id varchar(255) not null,
recipient_id varchar(255), recipient_id varchar(255),
target_group varchar(255) not null, role varchar(255) not null,
title varchar(255) not null, title varchar(255) not null,
type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')), type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')),
primary key (id) 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 alter table if exists application_form
add constraint FKhtad5onoy2jknhtyfmx6cvvey add constraint FKhtad5onoy2jknhtyfmx6cvvey
foreign key (created_by_id) foreign key (created_by_id)
@@ -122,3 +130,8 @@ alter table if exists notification
add constraint FKeg1j4hnp0y4lbm0y35hgr4e8r add constraint FKeg1j4hnp0y4lbm0y35hgr4e8r
foreign key (recipient_id) foreign key (recipient_id)
references app_user; references app_user;
alter table if exists user_organization_roles
add constraint FKhgmm93qre3up6hy63wcef3yqk
foreign key (user_id)
references app_user

View File

@@ -93,7 +93,7 @@ export function useAuth() {
redirectGuestTo: '/login' redirectGuestTo: '/login'
}) })
async function fetchSession() { async function fetchSession(targetPath?: string) {
if (sessionFetching.value) { if (sessionFetching.value) {
console.log('already fetching session') console.log('already fetching session')
return return
@@ -109,7 +109,7 @@ export function useAuth() {
sessionFetching.value = false sessionFetching.value = false
// Only fetch JWT and organizations if we have a session and not on public routes // 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() await fetchJwtAndOrganizations()
} }
@@ -160,10 +160,10 @@ export function useAuth() {
}) })
} }
function isPublicRoute(routeToCheck?: RouteLocationNormalizedLoaded) { function isPublicPath(path?: string) {
const finalRoute = routeToCheck ?? route const finalPath = path ?? route.path
const publicRoutes = ['/login', '/signup', '/accept-invitation'] 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 } = {}) { async function signOut({ redirectTo }: { redirectTo?: RouteLocationRaw } = {}) {
@@ -195,7 +195,7 @@ export function useAuth() {
fetchSession, fetchSession,
client, client,
jwt, jwt,
isPublicRoute, isPublicPath,
activeMember activeMember
} }
} }

View File

@@ -37,6 +37,19 @@ export function useUser() {
} }
} }
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> { async function deleteUser(id: string): Promise<void> {
try { try {
return await userApi.deleteUser(id) return await userApi.deleteUser(id)
@@ -53,6 +66,7 @@ export function useUser() {
return { return {
createUser, createUser,
getUserById, getUserById,
updateUser,
deleteUser deleteUser
} }
} }

View File

@@ -1,9 +1,4 @@
import { import { UserApi, Configuration, type CreateUserDto, type UserDto } from '~/.api-client'
UserApi,
Configuration,
type CreateUserDto,
type UserDto
} from '~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo' import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
export function useUserApi() { export function useUserApi() {
@@ -15,25 +10,32 @@ export function useUserApi() {
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath) cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : serverApiBaseUrl + serverApiBasePath)
) )
const userApiClient = new UserApi( // Track changing JWT of user who accepts the invitation
new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } }) const userApiClient = computed(
() =>
new UserApi(new Configuration({ basePath, headers: { Authorization: jwt.value ? `Bearer ${jwt.value}` : '' } }))
) )
async function createUser(createUserDto: CreateUserDto): Promise<UserDto> { async function createUser(createUserDto: CreateUserDto): Promise<UserDto> {
return userApiClient.createUser({ createUserDto }) return userApiClient.value.createUser({ createUserDto })
} }
async function getUserById(id: string): Promise<UserDto> { async function getUserById(id: string): Promise<UserDto> {
return userApiClient.getUserById({ id }) 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> { async function deleteUser(id: string): Promise<void> {
return userApiClient.deleteUser({ id }) return userApiClient.value.deleteUser({ id })
} }
return { return {
createUser, createUser,
getUserById, getUserById,
updateUser,
deleteUser deleteUser
} }
} }

View File

@@ -38,7 +38,7 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
console.log('[1] Auth middleware disabled for this route:', to.path) console.log('[1] Auth middleware disabled for this route:', to.path)
return return
} }
const { loggedIn, options, fetchSession, isPublicRoute } = useAuth() const { loggedIn, options, fetchSession, isPublicPath } = useAuth()
const { only, redirectUserTo, redirectGuestTo } = defu(to.meta?.auth, options) const { only, redirectUserTo, redirectGuestTo } = defu(to.meta?.auth, options)
// 2. If guest mode, redirect if authenticated // 2. If guest mode, redirect if authenticated
@@ -55,7 +55,7 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
if (import.meta.client) { if (import.meta.client) {
console.log('[3] Client-side navigation, fetching session') console.log('[3] Client-side navigation, fetching session')
try { try {
await fetchSession() await fetchSession(to.path)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
@@ -63,7 +63,7 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
// 4. If not authenticated, redirect to home or guest route // 4. If not authenticated, redirect to home or guest route
if (!loggedIn.value) { if (!loggedIn.value) {
if (isPublicRoute(to)) { if (isPublicPath(to.path)) {
console.log('[4] Not authenticated, but route is public:', to.path) console.log('[4] Not authenticated, but route is public:', to.path)
// Continue navigating to the public route // Continue navigating to the public route
return return

View File

@@ -24,7 +24,7 @@
"@nuxtjs/i18n": "10.0.3", "@nuxtjs/i18n": "10.0.3",
"@pinia/nuxt": "0.10.1", "@pinia/nuxt": "0.10.1",
"@vueuse/core": "^13.6.0", "@vueuse/core": "^13.6.0",
"better-auth": "1.3.4", "better-auth": "1.3.9",
"better-sqlite3": "11.8.1", "better-sqlite3": "11.8.1",
"nuxt": "3.16.1", "nuxt": "3.16.1",
"pinia": "3.0.1", "pinia": "3.0.1",

View File

@@ -10,7 +10,7 @@ importers:
dependencies: dependencies:
'@nuxt/ui-pro': '@nuxt/ui-pro':
specifier: 3.1.1 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': '@nuxtjs/i18n':
specifier: 10.0.3 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)) 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 specifier: ^13.6.0
version: 13.6.0(vue@3.5.13(typescript@5.7.3)) version: 13.6.0(vue@3.5.13(typescript@5.7.3))
better-auth: better-auth:
specifier: 1.3.4 specifier: 1.3.9
version: 1.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 1.3.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
better-sqlite3: better-sqlite3:
specifier: 11.8.1 specifier: 11.8.1
version: 11.8.1 version: 11.8.1
@@ -243,8 +243,8 @@ packages:
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@better-auth/utils@0.2.5': '@better-auth/utils@0.2.6':
resolution: {integrity: sha512-uI2+/8h/zVsH8RrYdG8eUErbuGBk16rZKQfz8CjxQOyCE6v7BqFYEbFwvOkvl1KbUdxhqOnXp78+uE5h8qVEgQ==} resolution: {integrity: sha512-3y/vaL5Ox33dBwgJ6ub3OPkVqr6B5xL2kgxNHG8eHZuryLyG/4JSPGqjbdRSgjuy9kALUZYDFl+ORIAxlWMSuA==}
'@better-fetch/fetch@1.1.18': '@better-fetch/fetch@1.1.18':
resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==}
@@ -1031,12 +1031,13 @@ packages:
resolution: {integrity: sha512-z6okREyK8in0486a22Oro0k+YsuyEjDXJt46FpgeOgXqKJ9ElM8QPll0iuLBkpbH33ENiNbIPLd1cuClRQnhiw==} resolution: {integrity: sha512-z6okREyK8in0486a22Oro0k+YsuyEjDXJt46FpgeOgXqKJ9ElM8QPll0iuLBkpbH33ENiNbIPLd1cuClRQnhiw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@noble/ciphers@0.6.0': '@noble/ciphers@2.0.0':
resolution: {integrity: sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ==} resolution: {integrity: sha512-j/l6jpnpaIBM87cAYPJzi/6TgqmBv9spkqPyCXvRYsu5uxqh6tPJZDnD85yo8VWqzTuTQPgfv7NgT63u7kbwAQ==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@1.8.0': '@noble/hashes@2.0.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} resolution: {integrity: sha512-h8VUBlE8R42+XIDO229cgisD287im3kdY6nbNZJFjc6ZvKIXPYXe6Vc/t+kyjFdMFyt5JpapzTsEg8n63w5/lw==}
engines: {node: ^14.21.3 || >=16} engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
@@ -1694,11 +1695,11 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0': '@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@simplewebauthn/browser@13.1.0': '@simplewebauthn/browser@13.1.2':
resolution: {integrity: sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==} resolution: {integrity: sha512-aZnW0KawAM83fSBUgglP5WofbrLbLyr7CoPqYr66Eppm7zO86YX6rrCjRB3hQKPrL7ATvY4FVXlykZ6w6FwYYw==}
'@simplewebauthn/server@13.1.1': '@simplewebauthn/server@13.1.2':
resolution: {integrity: sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==} resolution: {integrity: sha512-VwoDfvLXSCaRiD+xCIuyslU0HLxVggeE5BL06+GbsP2l1fGf5op8e0c3ZtKoi+vSg1q4ikjtAghC23ze2Q3H9g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@sindresorhus/is@7.0.1': '@sindresorhus/is@7.0.1':
@@ -2357,19 +2358,22 @@ packages:
resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
better-auth@1.3.4: better-auth@1.3.9:
resolution: {integrity: sha512-JbZYam6Cs3Eu5CSoMK120zSshfaKvrCftSo/+v7524H1RvhryQ7UtMbzagBcXj0Digjj8hZtVkkR4tTZD/wK2g==} resolution: {integrity: sha512-Ty6BHzuShlqSs7I4RMlBRQ3duOWNB7WWriIu2FJVGjQAOtTVvamzFCR4/j5ROFLoNkpvNTRF7BJozsrMICL1gw==}
peerDependencies: peerDependencies:
'@lynx-js/react': '*'
react: ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta: peerDependenciesMeta:
'@lynx-js/react':
optional: true
react: react:
optional: true optional: true
react-dom: react-dom:
optional: true optional: true
better-call@1.0.12: better-call@1.0.18:
resolution: {integrity: sha512-ssq5OfB9Ungv2M1WVrRnMBomB0qz1VKuhkY2WxjHaLtlsHoSe9EPolj1xf7xf8LY9o3vfk3Rx6rCWI4oVHeBRg==} resolution: {integrity: sha512-Ojyck3P3fs/egBmCW50tvfbCJorNV5KphfPOKrkCxPfOr8Brth1ruDtAJuhHVHEUiWrXv+vpEgWQk7m7FzhbbQ==}
better-sqlite3@11.8.1: better-sqlite3@11.8.1:
resolution: {integrity: sha512-9BxNaBkblMjhJW8sMRZxnxVTRgbRmssZW0Oxc1MPBTfiR+WW21e2Mk4qu8CzrcZb1LwPCnFsfDEzq+SNcBU8eg==} resolution: {integrity: sha512-9BxNaBkblMjhJW8sMRZxnxVTRgbRmssZW0Oxc1MPBTfiR+WW21e2Mk4qu8CzrcZb1LwPCnFsfDEzq+SNcBU8eg==}
@@ -3688,8 +3692,8 @@ packages:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true hasBin: true
jose@5.10.0: jose@6.1.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3766,8 +3770,8 @@ packages:
kolorist@1.8.0: kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
kysely@0.28.3: kysely@0.28.6:
resolution: {integrity: sha512-svKnkSH72APRdjfVCCOknxaC9Eb3nA2StHG9d5/sKOqRvHRp2Dtf1XwDvc92b4B5v6LV+EAGWXQbZ5jMOvHaDw==} resolution: {integrity: sha512-QQlpW/Db5yhhY9+c1jiCBCUCJqZoWLw6c1rE+H+FqGujMuIAxerCSdQNvyP3zyhQUO913J9Ank1NsQEb5a15mA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
launch-editor@2.10.0: launch-editor@2.10.0:
@@ -5746,33 +5750,33 @@ packages:
peerDependencies: peerDependencies:
zod: ^3.24.1 zod: ^3.24.1
zod@4.0.10: zod@4.1.8:
resolution: {integrity: sha512-3vB+UU3/VmLL2lvwcY/4RV2i9z/YU0DTV/tDuYjrwmx5WeJ7hwy+rGEEx8glHp6Yxw7ibRbKSaIFBgReRPe5KA==} resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==}
snapshots: snapshots:
'@ai-sdk/provider-utils@2.2.8(zod@4.0.10)': '@ai-sdk/provider-utils@2.2.8(zod@4.1.8)':
dependencies: dependencies:
'@ai-sdk/provider': 1.1.3 '@ai-sdk/provider': 1.1.3
nanoid: 3.3.11 nanoid: 3.3.11
secure-json-parse: 2.7.0 secure-json-parse: 2.7.0
zod: 4.0.10 zod: 4.1.8
'@ai-sdk/provider@1.1.3': '@ai-sdk/provider@1.1.3':
dependencies: dependencies:
json-schema: 0.4.0 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: dependencies:
'@ai-sdk/provider': 1.1.3 '@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@4.0.10) '@ai-sdk/provider-utils': 2.2.8(zod@4.1.8)
zod: 4.0.10 zod: 4.1.8
zod-to-json-schema: 3.24.5(zod@4.0.10) 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: dependencies:
'@ai-sdk/provider-utils': 2.2.8(zod@4.0.10) '@ai-sdk/provider-utils': 2.2.8(zod@4.1.8)
'@ai-sdk/ui-utils': 1.2.11(zod@4.0.10) '@ai-sdk/ui-utils': 1.2.11(zod@4.1.8)
swrv: 1.1.0(vue@3.5.13(typescript@5.7.3)) swrv: 1.1.0(vue@3.5.13(typescript@5.7.3))
optionalDependencies: optionalDependencies:
vue: 3.5.13(typescript@5.7.3) vue: 3.5.13(typescript@5.7.3)
@@ -5981,9 +5985,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1 '@babel/helper-validator-identifier': 7.27.1
'@better-auth/utils@0.2.5': '@better-auth/utils@0.2.6':
dependencies: dependencies:
typescript: 5.8.3
uncrypto: 0.1.3 uncrypto: 0.1.3
'@better-fetch/fetch@1.1.18': {} '@better-fetch/fetch@1.1.18': {}
@@ -6613,9 +6616,9 @@ snapshots:
'@netlify/serverless-functions-api@1.36.0': {} '@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': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
@@ -6983,12 +6986,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - 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: 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/kit': 3.17.2(magicast@0.3.5)
'@nuxt/schema': 3.17.2 '@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 '@standard-schema/spec': 1.0.0
'@vueuse/core': 13.6.0(vue@3.5.13(typescript@5.7.3)) '@vueuse/core': 13.6.0(vue@3.5.13(typescript@5.7.3))
consola: 3.4.2 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-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)) 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: optionalDependencies:
zod: 4.0.10 zod: 4.1.8
transitivePeerDependencies: transitivePeerDependencies:
- '@azure/app-configuration' - '@azure/app-configuration'
- '@azure/cosmos' - '@azure/cosmos'
@@ -7047,7 +7050,7 @@ snapshots:
- vue - vue
- vue-router - 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: dependencies:
'@iconify/vue': 4.3.0(vue@3.5.13(typescript@5.7.3)) '@iconify/vue': 4.3.0(vue@3.5.13(typescript@5.7.3))
'@internationalized/date': 3.8.0 '@internationalized/date': 3.8.0
@@ -7094,7 +7097,7 @@ snapshots:
vue-component-type-helpers: 2.2.10 vue-component-type-helpers: 2.2.10
optionalDependencies: optionalDependencies:
vue-router: 4.5.0(vue@3.5.13(typescript@5.7.3)) vue-router: 4.5.0(vue@3.5.13(typescript@5.7.3))
zod: 4.0.10 zod: 4.1.8
transitivePeerDependencies: transitivePeerDependencies:
- '@azure/app-configuration' - '@azure/app-configuration'
- '@azure/cosmos' - '@azure/cosmos'
@@ -7652,9 +7655,9 @@ snapshots:
domhandler: 5.0.3 domhandler: 5.0.3
selderee: 0.11.0 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: dependencies:
'@hexagon/base64': 1.1.28 '@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.11 '@levischuck/tiny-cbor': 0.2.11
@@ -8395,25 +8398,25 @@ snapshots:
basic-ftp@5.0.5: {} 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: dependencies:
'@better-auth/utils': 0.2.5 '@better-auth/utils': 0.2.6
'@better-fetch/fetch': 1.1.18 '@better-fetch/fetch': 1.1.18
'@noble/ciphers': 0.6.0 '@noble/ciphers': 2.0.0
'@noble/hashes': 1.8.0 '@noble/hashes': 2.0.0
'@simplewebauthn/browser': 13.1.0 '@simplewebauthn/browser': 13.1.2
'@simplewebauthn/server': 13.1.1 '@simplewebauthn/server': 13.1.2
better-call: 1.0.12 better-call: 1.0.18
defu: 6.1.4 defu: 6.1.4
jose: 5.10.0 jose: 6.1.0
kysely: 0.28.3 kysely: 0.28.6
nanostores: 0.11.4 nanostores: 0.11.4
zod: 4.0.10 zod: 4.1.8
optionalDependencies: optionalDependencies:
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
better-call@1.0.12: better-call@1.0.18:
dependencies: dependencies:
'@better-fetch/fetch': 1.1.18 '@better-fetch/fetch': 1.1.18
rou3: 0.5.1 rou3: 0.5.1
@@ -9875,7 +9878,7 @@ snapshots:
jiti@2.4.2: {} jiti@2.4.2: {}
jose@5.10.0: {} jose@6.1.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -9935,7 +9938,7 @@ snapshots:
kolorist@1.8.0: {} kolorist@1.8.0: {}
kysely@0.28.3: {} kysely@0.28.6: {}
launch-editor@2.10.0: launch-editor@2.10.0:
dependencies: dependencies:
@@ -12112,8 +12115,8 @@ snapshots:
compress-commons: 6.0.2 compress-commons: 6.0.2
readable-stream: 4.7.0 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: dependencies:
zod: 4.0.10 zod: 4.1.8
zod@4.0.10: {} zod@4.1.8: {}

View File

@@ -8,20 +8,50 @@ import {
worksCouncilMemberRole, worksCouncilMemberRole,
employeeRole, employeeRole,
adminRole, adminRole,
ownerRole,
ROLES, ROLES,
type LegalRole type LegalRole
} from './permissions' } from './permissions'
const db = new Database('./sqlite.db')
export const auth = betterAuth({ export const auth = betterAuth({
database: new Database('./sqlite.db'), database: db,
onAPIError: { throw: true }, onAPIError: { throw: true },
emailAndPassword: { enabled: true, autoSignIn: false }, emailAndPassword: { enabled: true, autoSignIn: false, minPasswordLength: 1 },
trustedOrigins: ['http://localhost:3001'], trustedOrigins: ['http://localhost:3001'],
plugins: [ plugins: [
jwt({ jwt({
jwt: { jwt: {
issuer: 'http://192.168.178.114:3001', 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: { jwks: {
keyPairConfig: { keyPairConfig: {
@@ -37,11 +67,10 @@ export const auth = betterAuth({
[ROLES.EMPLOYER]: employerRole, [ROLES.EMPLOYER]: employerRole,
[ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole, [ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole,
[ROLES.EMPLOYEE]: employeeRole, [ROLES.EMPLOYEE]: employeeRole,
[ROLES.ADMIN]: adminRole [ROLES.ADMIN]: adminRole,
[ROLES.OWNER]: ownerRole
}, },
creatorRole: ROLES.ADMIN, // OWNER fixen here!
// Creator gets admin role by default
creatorRole: ROLES.ADMIN,
async sendInvitationEmail(data) { async sendInvitationEmail(data) {
console.log('Sending invitation email', data) console.log('Sending invitation email', data)
@@ -51,7 +80,8 @@ export const auth = betterAuth({
[ROLES.EMPLOYER]: 'Arbeitgeber', [ROLES.EMPLOYER]: 'Arbeitgeber',
[ROLES.EMPLOYEE]: 'Arbeitnehmer', [ROLES.EMPLOYEE]: 'Arbeitnehmer',
[ROLES.WORKS_COUNCIL_MEMBER]: 'Betriebsrat', [ROLES.WORKS_COUNCIL_MEMBER]: 'Betriebsrat',
[ROLES.ADMIN]: 'Administrator' [ROLES.ADMIN]: 'Administrator',
[ROLES.OWNER]: 'Eigentümer'
} }
const roleDisplayName = roleDisplayNames[data.role as LegalRole] || data.role const roleDisplayName = roleDisplayNames[data.role as LegalRole] || data.role
@@ -70,7 +100,7 @@ export const auth = betterAuth({
}) })
if (result.error) { 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) console.log('Email invite link:', inviteLink)

View File

@@ -91,12 +91,33 @@ export const useOrganizationStore = defineStore('Organization', () => {
try { try {
await organizationApi.acceptInvitation(invitationId) await organizationApi.acceptInvitation(invitationId)
await navigateTo('/') await navigateTo('/')
await syncUserRoleToBackend()
} catch (e) { } catch (e) {
toast.add({ title: 'Error accepting invitation', color: 'error' }) toast.add({ title: 'Error accepting invitation', color: 'error' })
console.error('Error accepting invitation:', e) 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) { async function cancelSentInvitation(invitationId: string) {
try { try {
await organizationApi.cancelSentInvitation(invitationId) await organizationApi.cancelSentInvitation(invitationId)