diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/property/UserDirectoryDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/property/UserDirectoryDtos.kt new file mode 100644 index 0000000..b699614 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/property/UserDirectoryDtos.kt @@ -0,0 +1,38 @@ +package com.android.trisolarisserver.controller.dto.property + +import java.time.OffsetDateTime +import java.util.UUID + +data class AppUserSummaryResponse( + val id: UUID, + val phoneE164: String?, + val name: String?, + val disabled: Boolean, + val superAdmin: Boolean +) + +data class PropertyUserDetailsResponse( + val userId: UUID, + val propertyId: UUID, + val roles: Set, + val name: String?, + val phoneE164: String?, + val disabled: Boolean, + val superAdmin: Boolean +) + +data class PropertyAccessCodeCreateRequest( + val roles: Set +) + +data class PropertyAccessCodeResponse( + val propertyId: UUID, + val code: String, + val expiresAt: OffsetDateTime, + val roles: Set +) + +data class PropertyAccessCodeJoinRequest( + val propertyId: UUID, + val code: String +) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/property/PropertyAccessCodes.kt b/src/main/kotlin/com/android/trisolarisserver/controller/property/PropertyAccessCodes.kt new file mode 100644 index 0000000..b4bc144 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/property/PropertyAccessCodes.kt @@ -0,0 +1,149 @@ +package com.android.trisolarisserver.controller.property + +import com.android.trisolarisserver.component.auth.PropertyAccess +import com.android.trisolarisserver.controller.common.requirePrincipal +import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeCreateRequest +import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeJoinRequest +import com.android.trisolarisserver.controller.dto.property.PropertyAccessCodeResponse +import com.android.trisolarisserver.controller.dto.property.PropertyUserResponse +import com.android.trisolarisserver.models.property.PropertyAccessCode +import com.android.trisolarisserver.models.property.PropertyUser +import com.android.trisolarisserver.models.property.PropertyUserId +import com.android.trisolarisserver.models.property.Role +import com.android.trisolarisserver.repo.property.AppUserRepo +import com.android.trisolarisserver.repo.property.PropertyAccessCodeRepo +import com.android.trisolarisserver.repo.property.PropertyRepo +import com.android.trisolarisserver.repo.property.PropertyUserRepo +import com.android.trisolarisserver.security.MyPrincipal +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import org.springframework.transaction.annotation.Transactional +import java.security.SecureRandom +import java.time.OffsetDateTime +import java.util.UUID + +@RestController +class PropertyAccessCodes( + private val propertyAccess: PropertyAccess, + private val propertyRepo: PropertyRepo, + private val propertyUserRepo: PropertyUserRepo, + private val accessCodeRepo: PropertyAccessCodeRepo, + private val appUserRepo: AppUserRepo +) { + + private val secureRandom = SecureRandom() + + @PostMapping("/properties/{propertyId}/access-codes") + @ResponseStatus(HttpStatus.CREATED) + fun createAccessCode( + @PathVariable propertyId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: PropertyAccessCodeCreateRequest + ): PropertyAccessCodeResponse { + val resolved = requirePrincipal(principal) + propertyAccess.requireMember(propertyId, resolved.userId) + propertyAccess.requireAnyRole(propertyId, resolved.userId, Role.ADMIN) + + val roles = parseRoles(request.roles) + if (roles.contains(Role.ADMIN)) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "ADMIN cannot be invited by code") + } + if (roles.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one role is required") + } + + val property = propertyRepo.findById(propertyId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") + } + val actor = appUserRepo.findById(resolved.userId).orElseThrow { + ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") + } + val now = OffsetDateTime.now() + val expiresAt = now.plusMinutes(1) + val code = generateCode(propertyId, now) + + val accessCode = PropertyAccessCode( + property = property, + createdBy = actor, + code = code, + expiresAt = expiresAt, + roles = roles.toMutableSet() + ) + accessCodeRepo.save(accessCode) + + return PropertyAccessCodeResponse( + propertyId = propertyId, + code = code, + expiresAt = expiresAt, + roles = roles.map { it.name }.toSet() + ) + } + + @PostMapping("/properties/access-codes/join") + @Transactional + fun joinWithAccessCode( + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: PropertyAccessCodeJoinRequest + ): PropertyUserResponse { + val resolved = requirePrincipal(principal) + val code = request.code.trim() + if (code.length != 6 || !code.all { it.isDigit() }) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid code") + } + val now = OffsetDateTime.now() + val accessCode = accessCodeRepo.findActiveByPropertyAndCode(request.propertyId, code, now) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid code") + + val membershipId = PropertyUserId(propertyId = request.propertyId, userId = resolved.userId) + if (propertyUserRepo.existsById(membershipId)) { + throw ResponseStatusException(HttpStatus.CONFLICT, "User already a member") + } + + val user = appUserRepo.findById(resolved.userId).orElseThrow { + ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") + } + + val propertyUser = PropertyUser( + id = membershipId, + property = accessCode.property, + user = user, + roles = accessCode.roles.toMutableSet() + ) + propertyUserRepo.save(propertyUser) + + accessCode.usedAt = now + accessCode.usedBy = user + accessCodeRepo.save(accessCode) + + return PropertyUserResponse( + userId = membershipId.userId!!, + propertyId = membershipId.propertyId!!, + roles = propertyUser.roles.map { it.name }.toSet() + ) + } + + private fun parseRoles(input: Set): Set { + return try { + input.map { Role.valueOf(it) }.toSet() + } catch (ex: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown role") + } + } + + private fun generateCode(propertyId: UUID, now: OffsetDateTime): String { + repeat(6) { + val value = secureRandom.nextInt(1_000_000) + val code = String.format("%06d", value) + if (!accessCodeRepo.existsActiveByPropertyAndCode(propertyId, code, now)) { + return code + } + } + throw ResponseStatusException(HttpStatus.CONFLICT, "Unable to generate code, try again") + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/property/UserDirectory.kt b/src/main/kotlin/com/android/trisolarisserver/controller/property/UserDirectory.kt new file mode 100644 index 0000000..57b7632 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/property/UserDirectory.kt @@ -0,0 +1,78 @@ +package com.android.trisolarisserver.controller.property + +import com.android.trisolarisserver.component.auth.PropertyAccess +import com.android.trisolarisserver.controller.common.requirePrincipal +import com.android.trisolarisserver.controller.common.requireSuperAdmin +import com.android.trisolarisserver.controller.dto.property.AppUserSummaryResponse +import com.android.trisolarisserver.controller.dto.property.PropertyUserDetailsResponse +import com.android.trisolarisserver.models.property.Role +import com.android.trisolarisserver.repo.property.AppUserRepo +import com.android.trisolarisserver.repo.property.PropertyUserRepo +import com.android.trisolarisserver.security.MyPrincipal +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +class UserDirectory( + private val appUserRepo: AppUserRepo, + private val propertyUserRepo: PropertyUserRepo, + private val propertyAccess: PropertyAccess +) { + + @GetMapping("/users") + fun listAppUsers( + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestParam(required = false) phone: String? + ): List { + requireSuperAdmin(appUserRepo, principal) + val digits = phone?.filter { it.isDigit() }.orEmpty() + val users = when { + phone == null -> appUserRepo.findAll() + digits.length < 6 -> return emptyList() + else -> appUserRepo.findByPhoneE164Containing(digits) + } + return users.map { + AppUserSummaryResponse( + id = it.id!!, + phoneE164 = it.phoneE164, + name = it.name, + disabled = it.disabled, + superAdmin = it.superAdmin + ) + } + } + + @GetMapping("/properties/{propertyId}/users/search") + fun searchPropertyUsers( + @PathVariable propertyId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestParam(required = false) phone: String? + ): List { + val resolved = requirePrincipal(principal) + propertyAccess.requireMember(propertyId, resolved.userId) + propertyAccess.requireAnyRole(propertyId, resolved.userId, Role.ADMIN) + + val digits = phone?.filter { it.isDigit() }.orEmpty() + val users = when { + phone == null -> propertyUserRepo.findByPropertyIdWithUser(propertyId) + digits.length < 6 -> return emptyList() + else -> propertyUserRepo.findByPropertyIdAndPhoneLike(propertyId, digits) + } + return users.map { + val user = it.user + PropertyUserDetailsResponse( + userId = it.id.userId!!, + propertyId = it.id.propertyId!!, + roles = it.roles.map { role -> role.name }.toSet(), + name = user.name, + phoneE164 = user.phoneE164, + disabled = user.disabled, + superAdmin = user.superAdmin + ) + } + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/models/property/PropertyAccessCode.kt b/src/main/kotlin/com/android/trisolarisserver/models/property/PropertyAccessCode.kt new file mode 100644 index 0000000..9782994 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/property/PropertyAccessCode.kt @@ -0,0 +1,62 @@ +package com.android.trisolarisserver.models.property + +import jakarta.persistence.CollectionTable +import jakarta.persistence.Column +import jakarta.persistence.ElementCollection +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table( + name = "property_access_code", + indexes = [ + Index(name = "idx_property_access_code_property", columnList = "property_id"), + Index(name = "idx_property_access_code_code", columnList = "property_id,code") + ] +) +class PropertyAccessCode( + @Id + @GeneratedValue + @Column(columnDefinition = "uuid") + val id: UUID? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_id") + var property: Property, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by_user_id") + var createdBy: AppUser, + + @Column(nullable = false) + var code: String, + + @Column(name = "expires_at", nullable = false, columnDefinition = "timestamptz") + var expiresAt: OffsetDateTime, + + @Column(name = "used_at", columnDefinition = "timestamptz") + var usedAt: OffsetDateTime? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "used_by_user_id") + var usedBy: AppUser? = null, + + @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") + val createdAt: OffsetDateTime = OffsetDateTime.now(), + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "property_access_code_role", + joinColumns = [JoinColumn(name = "access_code_id")] + ) + @Column(name = "role") + var roles: MutableSet = mutableSetOf() +) diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/property/AppUserRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/property/AppUserRepo.kt index ae2058d..8fcd0b5 100644 --- a/src/main/kotlin/com/android/trisolarisserver/repo/property/AppUserRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/repo/property/AppUserRepo.kt @@ -7,4 +7,5 @@ import java.util.UUID interface AppUserRepo : JpaRepository { fun findByFirebaseUid(firebaseUid: String): AppUser? fun existsByFirebaseUid(firebaseUid: String): Boolean + fun findByPhoneE164Containing(phoneE164: String): List } diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyAccessCodeRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyAccessCodeRepo.kt new file mode 100644 index 0000000..8ded20e --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyAccessCodeRepo.kt @@ -0,0 +1,43 @@ +package com.android.trisolarisserver.repo.property + +import com.android.trisolarisserver.models.property.PropertyAccessCode +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.OffsetDateTime +import java.util.UUID + +interface PropertyAccessCodeRepo : JpaRepository { + + @Query( + """ + select pac + from PropertyAccessCode pac + where pac.property.id = :propertyId + and pac.code = :code + and pac.usedAt is null + and pac.expiresAt > :now + """ + ) + fun findActiveByPropertyAndCode( + @Param("propertyId") propertyId: UUID, + @Param("code") code: String, + @Param("now") now: OffsetDateTime + ): PropertyAccessCode? + + @Query( + """ + select case when count(pac) > 0 then true else false end + from PropertyAccessCode pac + where pac.property.id = :propertyId + and pac.code = :code + and pac.usedAt is null + and pac.expiresAt > :now + """ + ) + fun existsActiveByPropertyAndCode( + @Param("propertyId") propertyId: UUID, + @Param("code") code: String, + @Param("now") now: OffsetDateTime + ): Boolean +} diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyUserRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyUserRepo.kt index 2ac52a3..506c86d 100644 --- a/src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyUserRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyUserRepo.kt @@ -14,6 +14,31 @@ interface PropertyUserRepo : JpaRepository { fun findByIdPropertyId(propertyId: UUID): List + @Query( + """ + select pu + from PropertyUser pu + join fetch pu.user u + where pu.id.propertyId = :propertyId + """ + ) + fun findByPropertyIdWithUser(@Param("propertyId") propertyId: UUID): List + + @Query( + """ + select pu + from PropertyUser pu + join fetch pu.user u + where pu.id.propertyId = :propertyId + and u.phoneE164 is not null + and u.phoneE164 like concat('%', :phone, '%') + """ + ) + fun findByPropertyIdAndPhoneLike( + @Param("propertyId") propertyId: UUID, + @Param("phone") phone: String + ): List + @Query(""" select r from PropertyUser pu join pu.roles r