diff --git a/src/main/frontend/components/administration/UserManagement.tsx b/src/main/frontend/components/administration/UserManagement.tsx index e44180a..89469dc 100644 --- a/src/main/frontend/components/administration/UserManagement.tsx +++ b/src/main/frontend/components/administration/UserManagement.tsx @@ -6,9 +6,12 @@ import {ConfigEndpoint, UserEndpoint} from "Frontend/generated/endpoints"; import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto"; import {UserManagementCard} from "Frontend/components/general/UserManagementCard"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; -import {Info} from "@phosphor-icons/react"; +import {Info, UserPlus} from "@phosphor-icons/react"; +import {Button, Divider, Tooltip, useDisclosure} from "@nextui-org/react"; +import InviteUserModal from "Frontend/components/general/InviteUserModal"; function UserManagementLayout({getConfig, formik}: any) { + const inviteUserModal = useDisclosure(); const [users, setUsers] = useState([]); const [autoRegisterNewUsers, setAutoRegisterNewUsers] = useState(true); @@ -32,7 +35,15 @@ function UserManagementLayout({getConfig, formik}: any) { isDisabled={!formik.values.users["sign-ups"].allow}/> -
+
+

Users

+ + + +
+ {!autoRegisterNewUsers && @@ -40,6 +51,7 @@ function UserManagementLayout({getConfig, formik}: any) {
{users.map((user) => )}
+ ); } diff --git a/src/main/frontend/components/general/InviteUserModal.tsx b/src/main/frontend/components/general/InviteUserModal.tsx new file mode 100644 index 0000000..04f4b84 --- /dev/null +++ b/src/main/frontend/components/general/InviteUserModal.tsx @@ -0,0 +1,65 @@ +import React, {useEffect, useState} from "react"; +import {Button, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react"; +import {RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints"; +import {toast} from "sonner"; + +interface InviteUserModalProps { + isOpen: boolean; + onOpenChange: () => void; +} + +export default function InviteUserModal({ + isOpen, + onOpenChange + }: InviteUserModalProps) { + const [email, setEmail] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + setEmail(null); + setError(null); + }, []); + + async function inviteUser(onClose: () => void) { + if (email === null) return; + + if (await UserEndpoint.existsByMail(email)) { + setError("User with this email already exists"); + return; + } + + try { + await RegistrationEndpoint.createInvitation(email); + toast.success("Invitation has been sent"); + onClose(); + } catch (e) { + setError("Failed to create invitation"); + } + } + + return ( + + + {(onClose) => ( + <> + Invite a new user + +

Enter the email address of the user you want to invite:

+ setEmail(e.target.value)} type="email"/> + {error && {error}} +
+ + + + + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/main/frontend/routes.tsx b/src/main/frontend/routes.tsx index fca568f..0fcb65e 100644 --- a/src/main/frontend/routes.tsx +++ b/src/main/frontend/routes.tsx @@ -16,6 +16,7 @@ import {MessageManagement} from "Frontend/components/administration/MessageManag import {LogManagement} from "Frontend/components/administration/LogManagement"; import PasswordResetView from "Frontend/views/PasswordResetView"; import EmailConfirmationView from "Frontend/views/EmailConfirmationView"; +import InvitationRegistrationView from "Frontend/views/InvitationRegistrationView"; export const routes = protectRoutes([ { @@ -56,12 +57,15 @@ export const routes = protectRoutes([ { path: '/setup', element: , handle: {requiresLogin: false} }, + { + path: '/accept-invitation', element: , handle: {requiresLogin: false} + }, { path: '/reset-password', element: , handle: {requiresLogin: false} }, { path: '/confirm-email', element: , handle: {requiresLogin: true} - } + }, ], } ]) as RouteObject[]; diff --git a/src/main/frontend/views/InvitationRegistrationView.tsx b/src/main/frontend/views/InvitationRegistrationView.tsx new file mode 100644 index 0000000..2678a0d --- /dev/null +++ b/src/main/frontend/views/InvitationRegistrationView.tsx @@ -0,0 +1,109 @@ +import {Button, Card, CardBody, CardHeader} from "@nextui-org/react"; +import {useNavigate, useSearchParams} from "react-router-dom"; +import {Form, Formik} from "formik"; +import Input from "Frontend/components/general/Input"; +import * as Yup from "yup"; +import {RegistrationEndpoint} from "Frontend/generated/endpoints"; +import React, {useEffect, useState} from "react"; +import {Warning} from "@phosphor-icons/react"; +import {toast} from "sonner"; +import TokenValidationResult from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenValidationResult"; + +export default function InvitationRegistrationView() { + const [searchParams, setSearchParams] = useSearchParams(); + const [token, setToken] = useState(); + const [email, setEmail] = useState(); + const navigate = useNavigate(); + + useEffect(() => { + let token = searchParams.get("token"); + if (token) { + setToken(token); + RegistrationEndpoint.getInvitationRecipientEmail(token).then(setEmail); + } + }, [searchParams]); + + async function register(values: any) { + let result = await RegistrationEndpoint.acceptInvitation(token, { + email: email, + username: values.username, + password: values.password + }); + + switch (result) { + case TokenValidationResult.VALID: + toast.success("Registration successful"); + navigate("/", {replace: true}); + break; + case TokenValidationResult.EXPIRED: + toast.error("Token is expired"); + break; + case TokenValidationResult.INVALID: + default: + toast.error("Token is invalid"); + break + } + } + + return ( +
+ + + Gameyfin Logo + + + {token ? + + {(formik: { values: any; isSubmitting: any; isValid: boolean; }) => ( +
+

Register a new account

+ + + + + +
+ )} +
+ : +

+ + Invalid token +

+ } +
+
+
+ ); +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt b/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt index fe25f95..4b4057c 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt @@ -2,11 +2,13 @@ package de.grimsi.gameyfin.core.events import de.grimsi.gameyfin.shared.token.Token import de.grimsi.gameyfin.shared.token.TokenType.EmailConfirmation +import de.grimsi.gameyfin.shared.token.TokenType.Invitation import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset import de.grimsi.gameyfin.users.entities.User import org.springframework.context.ApplicationEvent -class UserInvitationEvent(source: Any) : ApplicationEvent(source) +class UserInvitationEvent(source: Any, val token: Token, val baseUrl: String, val email: String) : + ApplicationEvent(source) class UserRegistrationWaitingForApprovalEvent(source: Any, val newUser: User) : ApplicationEvent(source) diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/security/EncryptionConverter.kt b/src/main/kotlin/de/grimsi/gameyfin/core/security/EncryptionConverter.kt index 05787c1..c81d094 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/core/security/EncryptionConverter.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/core/security/EncryptionConverter.kt @@ -2,57 +2,14 @@ package de.grimsi.gameyfin.core.security import jakarta.persistence.AttributeConverter import jakarta.persistence.Converter -import java.util.* -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec @Converter class EncryptionConverter : AttributeConverter { - - companion object { - private const val ALGORITHM = "AES" - private val SECRET_KEY: SecretKeySpec - - init { - val base64Key = System.getenv("APP_KEY") - ?: throw IllegalStateException("APP_KEY environment variable is not set or empty") - - val decodedKey = Base64.getDecoder().decode(base64Key) - - // Ensure the key length is valid for AES (128, 192, or 256 bits) - if (decodedKey.size !in listOf(16, 24, 32)) { - throw IllegalArgumentException("Invalid AES key length. Key must be 128, 192, or 256 bits.") - } - - SECRET_KEY = SecretKeySpec(decodedKey, ALGORITHM) - } - } - override fun convertToDatabaseColumn(attribute: String?): String? { - return attribute?.let { - try { - val cipher = Cipher.getInstance(ALGORITHM).apply { - init(Cipher.ENCRYPT_MODE, SECRET_KEY) - } - val encryptedBytes = cipher.doFinal(it.toByteArray()) - Base64.getEncoder().encodeToString(encryptedBytes) - } catch (e: Exception) { - throw RuntimeException("Error during encryption", e) - } - } + return attribute?.let { EncryptionUtils.encrypt(it) } } override fun convertToEntityAttribute(dbData: String?): String? { - return dbData?.let { - try { - val cipher = Cipher.getInstance(ALGORITHM).apply { - init(Cipher.DECRYPT_MODE, SECRET_KEY) - } - val decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(it)) - String(decryptedBytes) - } catch (e: Exception) { - throw RuntimeException("Error during decryption", e) - } - } + return dbData?.let { EncryptionUtils.decrypt(it) } } } diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/security/EncryptionMapConverter.kt b/src/main/kotlin/de/grimsi/gameyfin/core/security/EncryptionMapConverter.kt new file mode 100644 index 0000000..e78eccf --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/core/security/EncryptionMapConverter.kt @@ -0,0 +1,27 @@ +package de.grimsi.gameyfin.core.security + +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter +class EncryptionMapConverter : AttributeConverter, String> { + companion object { + private val objectMapper = ObjectMapper() + } + + override fun convertToDatabaseColumn(attribute: Map?): String? { + return attribute?.let { + val jsonString = objectMapper.writeValueAsString(it) + EncryptionUtils.encrypt(jsonString) + } + } + + @Suppress("UNCHECKED_CAST") + override fun convertToEntityAttribute(dbData: String?): Map? { + return dbData?.let { + val decryptedString = EncryptionUtils.decrypt(it) + objectMapper.readValue(decryptedString, Map::class.java) as Map? + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/security/EncryptionUtils.kt b/src/main/kotlin/de/grimsi/gameyfin/core/security/EncryptionUtils.kt new file mode 100644 index 0000000..d092181 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/core/security/EncryptionUtils.kt @@ -0,0 +1,42 @@ +package de.grimsi.gameyfin.core.security + +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +class EncryptionUtils { + companion object { + private const val ALGORITHM = "AES" + private val SECRET_KEY: SecretKeySpec + + init { + val base64Key = System.getenv("APP_KEY") + ?: throw IllegalStateException("APP_KEY environment variable is not set or empty") + + val decodedKey = Base64.getDecoder().decode(base64Key) + + // Ensure the key length is valid for AES (128, 192, or 256 bits) + if (decodedKey.size !in listOf(16, 24, 32)) { + throw IllegalArgumentException("Invalid AES key length. Key must be 128, 192, or 256 bits.") + } + + SECRET_KEY = SecretKeySpec(decodedKey, ALGORITHM) + } + + fun encrypt(value: String): String { + val cipher = Cipher.getInstance(ALGORITHM).apply { + init(Cipher.ENCRYPT_MODE, SECRET_KEY) + } + val encryptedBytes = cipher.doFinal(value.toByteArray()) + return Base64.getEncoder().encodeToString(encryptedBytes) + } + + fun decrypt(value: String): String { + val cipher = Cipher.getInstance(ALGORITHM).apply { + init(Cipher.DECRYPT_MODE, SECRET_KEY) + } + val decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(value)) + return String(decryptedBytes) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt index aec86a3..07b24ca 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt @@ -89,10 +89,10 @@ class MessageService( val token = event.token val resetLink = event.baseUrl + "/reset-password?token=${token.secret}" sendNotification( - token.user.email, + token.creator.email, "[Gameyfin] Password Reset Request", MessageTemplates.PasswordResetRequest, - mapOf("username" to token.user.username, "resetLink" to resetLink) + mapOf("username" to token.creator.username, "resetLink" to resetLink) ) } @@ -167,7 +167,7 @@ class MessageService( log.info { "Sending email confirmation notification" } - val user = event.token.user + val user = event.token.creator val confirmationLink = event.baseUrl + "/confirm-email?token=${event.token.secret}" sendNotification( user.email, @@ -176,4 +176,24 @@ class MessageService( mapOf("username" to user.username, "confirmationLink" to confirmationLink) ) } + + @Async + @EventListener(UserInvitationEvent::class) + fun onUserInvitation(event: UserInvitationEvent) { + + if (!enabled) { + log.error { "No notification provider available, can't send invitation message" } + return + } + + log.info { "Sending invitation notification" } + + val invitationLink = event.baseUrl + "/accept-invitation?token=${event.token.secret}" + sendNotification( + event.email, + "[Gameyfin] You've been invited!", + MessageTemplates.UserInvitation, + mapOf("invitationLink" to invitationLink) + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/Token.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/Token.kt index ff4ef41..4ad7367 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/shared/token/Token.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/Token.kt @@ -1,9 +1,12 @@ package de.grimsi.gameyfin.shared.token import de.grimsi.gameyfin.core.security.EncryptionConverter +import de.grimsi.gameyfin.core.security.EncryptionMapConverter import de.grimsi.gameyfin.users.entities.User import jakarta.persistence.* import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.OnDelete +import org.hibernate.annotations.OnDeleteAction import org.hibernate.annotations.Type import java.time.Instant import java.util.* @@ -18,8 +21,12 @@ class Token( @Type(TokenTypeUserType::class) val type: T, - @OneToOne(targetEntity = User::class, fetch = FetchType.EAGER) - val user: User, + @ManyToOne(targetEntity = User::class, fetch = FetchType.EAGER) + @OnDelete(action = OnDeleteAction.CASCADE) + val creator: User, + + @Convert(converter = EncryptionMapConverter::class) + val payload: Map = emptyMap(), @CreationTimestamp val createdOn: Instant? = null diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenDto.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenDto.kt index 04641a8..b409d8f 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenDto.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenDto.kt @@ -6,11 +6,15 @@ import kotlin.time.toJavaDuration data class TokenDto( val secret: String, val type: String, - val expiresAt: Instant + val expiresAt: String ) { constructor(token: Token<*>) : this( secret = token.secret, type = token.type.key, - expiresAt = token.createdOn?.plus(token.type.expiration.toJavaDuration()) ?: Instant.MIN + expiresAt = if (token.type.expiration.isFinite()) { + token.createdOn?.plus(token.type.expiration.toJavaDuration())?.toString() ?: Instant.MIN.toString() + } else { + "never" + } ) } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenRepository.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenRepository.kt index 8629f39..b7189e6 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenRepository.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenRepository.kt @@ -5,5 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface TokenRepository : JpaRepository, String> { fun findBySecret(secret: String): Token<*>? - fun findByUserAndType(user: User, type: T): Token? + fun findByCreatorAndType(creator: User, type: T): Token? + fun findByCreatorAndTypeAndPayload(creator: User, type: T, payload: Map): Token? } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenService.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenService.kt index 85df0fb..9da7357 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenService.kt @@ -14,11 +14,11 @@ abstract class TokenService( @Transactional open fun generate(user: User): Token { val token = Token( - user = user, + creator = user, type = type ) - tokenRepository.findByUserAndType(user, type)?.let { + tokenRepository.findByCreatorAndType(user, type)?.let { log.warn { "Deleting existing ${it.type.key} token for user '${user.username}'" } delete(it) } @@ -26,6 +26,22 @@ abstract class TokenService( return tokenRepository.save(token) } + @Transactional + open fun generateWithPayload(user: User, payload: Map): Token { + val token = Token( + creator = user, + type = type, + payload = payload + ) + + tokenRepository.findByCreatorAndTypeAndPayload(user, type, payload)?.let { + log.warn { "Deleting existing ${it.type.key} token of user '${user.username}' with same payload" } + delete(it) + } + + return tokenRepository.save(token) + } + @Transactional open fun get(secret: String, type: T): Token? { val token = tokenRepository.findBySecret(secret) ?: return null @@ -39,6 +55,11 @@ abstract class TokenService( } } + @Transactional + open fun getPayload(secret: String): Map? { + return tokenRepository.findBySecret(secret)?.payload + } + @Transactional open fun delete(token: Token) { try { diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt index 9862e61..69418d3 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt @@ -14,6 +14,11 @@ import org.springframework.security.core.context.SecurityContextHolder class UserEndpoint( private val userService: UserService ) { + @PermitAll + fun existsByMail(email: String): Boolean { + return userService.existsByEmail(email) + } + @PermitAll fun getUserInfo(): UserInfoDto { val auth: Authentication = SecurityContextHolder.getContext().authentication diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt index 6c67b8f..cf51d34 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt @@ -66,6 +66,7 @@ class UserService( } fun existsByUsername(username: String): Boolean = userRepository.existsByUsername(username) + fun existsByEmail(email: String): Boolean = userRepository.existsByEmail(email) fun findByOidcProviderId(oidcProviderId: String): User? = userRepository.findByOidcProviderId(oidcProviderId) @@ -178,6 +179,19 @@ class UserService( } } + fun registerUserFromInvitation(user: UserRegistrationDto, email: String): User { + val user = User( + username = user.username, + password = passwordEncoder.encode(user.password), + email = email, + emailConfirmed = true, + enabled = true, + roles = roleService.toRoles(listOf(Roles.USER)) + ) + + return userRepository.save(user) + } + fun updateUser(username: String, updates: UserUpdateDto) { val user = userByUsername(username) diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/emailconfirmation/EmailConfirmationService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/emailconfirmation/EmailConfirmationService.kt index e97fc96..a16c61b 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/emailconfirmation/EmailConfirmationService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/emailconfirmation/EmailConfirmationService.kt @@ -30,7 +30,7 @@ class EmailConfirmationService( return TokenValidationResult.EXPIRED } - val user = emailConfirmationToken.user + val user = emailConfirmationToken.creator confirmEmail(user) delete(emailConfirmationToken) diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/passwordreset/PasswordResetService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/passwordreset/PasswordResetService.kt index 0526370..be89a2d 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/passwordreset/PasswordResetService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/passwordreset/PasswordResetService.kt @@ -99,7 +99,7 @@ class PasswordResetService( return TokenValidationResult.EXPIRED } - val user = passwordResetToken.user + val user = passwordResetToken.creator userService.updatePassword(user, newPassword) delete(passwordResetToken) diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt index fd3d6a9..271f3cd 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository interface UserRepository : JpaRepository { fun existsByUsername(userName: String): Boolean + fun existsByEmail(email: String): Boolean fun findByUsername(userName: String): User? fun findByEmail(email: String): User? fun findByOidcProviderId(oidcProviderId: String): User? diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/registration/InvitationService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/registration/InvitationService.kt new file mode 100644 index 0000000..7e3390f --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/registration/InvitationService.kt @@ -0,0 +1,54 @@ +package de.grimsi.gameyfin.users.registration + +import de.grimsi.gameyfin.core.Utils +import de.grimsi.gameyfin.core.events.UserInvitationEvent +import de.grimsi.gameyfin.shared.token.TokenDto +import de.grimsi.gameyfin.shared.token.TokenRepository +import de.grimsi.gameyfin.shared.token.TokenService +import de.grimsi.gameyfin.shared.token.TokenType.Invitation +import de.grimsi.gameyfin.shared.token.TokenValidationResult +import de.grimsi.gameyfin.users.UserService +import de.grimsi.gameyfin.users.dto.UserRegistrationDto +import org.springframework.context.ApplicationEventPublisher +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Service + +@Service +class InvitationService( + tokenRepository: TokenRepository, + private val userService: UserService, + private val eventPublisher: ApplicationEventPublisher +) : TokenService(Invitation, tokenRepository) { + + companion object { + private const val EMAIL_KEY = "email" + } + + fun createInvitation(email: String): TokenDto { + if (userService.existsByEmail(email)) + throw IllegalStateException("User with email ${Utils.maskEmail(email)} is already registered") + + val auth: Authentication = SecurityContextHolder.getContext().authentication + val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found") + val payload = mapOf(EMAIL_KEY to email) + val token = super.generateWithPayload(user, payload) + + eventPublisher.publishEvent(UserInvitationEvent(this, token, Utils.getBaseUrl(), email)) + return TokenDto(token) + } + + fun getAssociatedEmail(secret: String): String? { + val payload = super.getPayload(secret) ?: return null + return payload[EMAIL_KEY] + } + + fun acceptInvitation(secret: String, registration: UserRegistrationDto): TokenValidationResult { + val invitationToken = super.get(secret, Invitation) ?: return TokenValidationResult.INVALID + val email = invitationToken.payload[EMAIL_KEY] ?: return TokenValidationResult.INVALID + if (invitationToken.expired) return TokenValidationResult.EXPIRED + + userService.registerUserFromInvitation(registration, email) + return TokenValidationResult.VALID + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/registration/RegistrationEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/registration/RegistrationEndpoint.kt index 46dec4a..ad313f0 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/registration/RegistrationEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/registration/RegistrationEndpoint.kt @@ -3,6 +3,8 @@ package de.grimsi.gameyfin.users.registration import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.shared.token.TokenDto +import de.grimsi.gameyfin.shared.token.TokenValidationResult import de.grimsi.gameyfin.users.UserService import de.grimsi.gameyfin.users.dto.UserRegistrationDto import jakarta.annotation.security.RolesAllowed @@ -10,7 +12,8 @@ import jakarta.annotation.security.RolesAllowed @AnonymousAllowed @Endpoint class RegistrationEndpoint( - private val userService: UserService + private val userService: UserService, + private val invitationService: InvitationService ) { fun isSelfRegistrationAllowed(): Boolean { return userService.selfRegistrationAllowed @@ -26,8 +29,21 @@ class RegistrationEndpoint( return !userService.existsByUsername(username) } + fun acceptInvitation(token: String, registration: UserRegistrationDto): TokenValidationResult { + return invitationService.acceptInvitation(token, registration) + } + + fun getInvitationRecipientEmail(token: String): String? { + return invitationService.getAssociatedEmail(token) + } + @RolesAllowed(Roles.Names.ADMIN) fun confirmRegistration(username: String) { userService.confirmRegistration(username) } + + @RolesAllowed(Roles.Names.ADMIN) + fun createInvitation(email: String): TokenDto { + return invitationService.createInvitation(email) + } } \ No newline at end of file