mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Implement user invitation flow
This commit is contained in:
@@ -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<UserInfoDto[]>([]);
|
||||
const [autoRegisterNewUsers, setAutoRegisterNewUsers] = useState(true);
|
||||
|
||||
@@ -32,7 +35,15 @@ function UserManagementLayout({getConfig, formik}: any) {
|
||||
isDisabled={!formik.values.users["sign-ups"].allow}/>
|
||||
</div>
|
||||
|
||||
<Section title="Users"/>
|
||||
<div className="flex flex-row items-baseline justify-between">
|
||||
<h2 className={"text-xl font-bold mt-8 mb-1"}>Users</h2>
|
||||
<Tooltip content="Invite new user">
|
||||
<Button isIconOnly variant="faded" onPress={inviteUserModal.onOpen}>
|
||||
<UserPlus/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Divider className="mb-4"/>
|
||||
{!autoRegisterNewUsers &&
|
||||
<SmallInfoField className="mb-4 text-warning" icon={Info}
|
||||
message="Automatic user registration for SSO users is disabled"/>
|
||||
@@ -40,6 +51,7 @@ function UserManagementLayout({getConfig, formik}: any) {
|
||||
<div className="grid grid-cols-300px gap-4">
|
||||
{users.map((user) => <UserManagementCard user={user} key={user.username}/>)}
|
||||
</div>
|
||||
<InviteUserModal isOpen={inviteUserModal.isOpen} onOpenChange={inviteUserModal.onOpenChange}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>();
|
||||
const [error, setError] = useState<string | null>();
|
||||
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">Invite a new user</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>Enter the email address of the user you want to invite:</p>
|
||||
<Input errorMessage={error} onChange={(e) => setEmail(e.target.value)} type="email"/>
|
||||
{error && <small className="text-danger">{error}</small>}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="success" onPress={() => inviteUser(onClose)}
|
||||
isDisabled={email === null || email === undefined || email.length < 1}>
|
||||
Send invitation
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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: <SetupView/>, handle: {requiresLogin: false}
|
||||
},
|
||||
{
|
||||
path: '/accept-invitation', element: <InvitationRegistrationView/>, handle: {requiresLogin: false}
|
||||
},
|
||||
{
|
||||
path: '/reset-password', element: <PasswordResetView/>, handle: {requiresLogin: false}
|
||||
},
|
||||
{
|
||||
path: '/confirm-email', element: <EmailConfirmationView/>, handle: {requiresLogin: true}
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
]) as RouteObject[];
|
||||
|
||||
@@ -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<string>();
|
||||
const [email, setEmail] = useState<string>();
|
||||
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 (
|
||||
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary">
|
||||
<Card className="p-4 min-w-[468px]">
|
||||
<CardHeader className="mb-4">
|
||||
<img
|
||||
className="h-28 w-full content-center"
|
||||
src="/images/Logo.svg"
|
||||
alt="Gameyfin Logo"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{token ?
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{
|
||||
username: "",
|
||||
email: email,
|
||||
password: "",
|
||||
passwordRepeat: ""
|
||||
}}
|
||||
validationSchema={Yup.object({
|
||||
username: Yup.string()
|
||||
.required('Required'),
|
||||
password: Yup.string()
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.required('Required'),
|
||||
email: Yup.string()
|
||||
.email()
|
||||
.required('Required'),
|
||||
passwordRepeat: Yup.string()
|
||||
.equals([Yup.ref('password')], 'Passwords do not match')
|
||||
.required('Required')
|
||||
})}
|
||||
onSubmit={register}>
|
||||
{(formik: { values: any; isSubmitting: any; isValid: boolean; }) => (
|
||||
<Form>
|
||||
<p className="text-xl text-center mb-8">Register a new account</p>
|
||||
<Input label="Email" name="email" type="email" value={email} disabled/>
|
||||
<Input label="Username" name="username" autoComplete="username"/>
|
||||
<Input label="Password" name="password" type="password"
|
||||
autoComplete="new-password"/>
|
||||
<Input label="Password (repeat)" name="passwordRepeat" type="password"
|
||||
autoComplete="new-password"/>
|
||||
<Button type="submit" className="w-full mt-4" color="primary"
|
||||
isDisabled={!formik.isValid || formik.isSubmitting}
|
||||
isLoading={formik.isSubmitting}>
|
||||
{formik.isSubmitting ? "" : "Create account"}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
:
|
||||
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold">
|
||||
<Warning weight="fill"/>
|
||||
Invalid token
|
||||
</p>
|
||||
}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Invitation>, val baseUrl: String, val email: String) :
|
||||
ApplicationEvent(source)
|
||||
|
||||
class UserRegistrationWaitingForApprovalEvent(source: Any, val newUser: User) : ApplicationEvent(source)
|
||||
|
||||
|
||||
@@ -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<String, String> {
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Map<String, String>, String> {
|
||||
companion object {
|
||||
private val objectMapper = ObjectMapper()
|
||||
}
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: Map<String, String>?): String? {
|
||||
return attribute?.let {
|
||||
val jsonString = objectMapper.writeValueAsString(it)
|
||||
EncryptionUtils.encrypt(jsonString)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun convertToEntityAttribute(dbData: String?): Map<String, String>? {
|
||||
return dbData?.let {
|
||||
val decryptedString = EncryptionUtils.decrypt(it)
|
||||
objectMapper.readValue(decryptedString, Map::class.java) as Map<String, String>?
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<T : TokenType>(
|
||||
@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<String, String> = emptyMap(),
|
||||
|
||||
@CreationTimestamp
|
||||
val createdOn: Instant? = null
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -5,5 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface TokenRepository : JpaRepository<Token<*>, String> {
|
||||
fun findBySecret(secret: String): Token<*>?
|
||||
fun <T : TokenType> findByUserAndType(user: User, type: T): Token<T>?
|
||||
fun <T : TokenType> findByCreatorAndType(creator: User, type: T): Token<T>?
|
||||
fun <T : TokenType> findByCreatorAndTypeAndPayload(creator: User, type: T, payload: Map<String, String>): Token<T>?
|
||||
}
|
||||
@@ -14,11 +14,11 @@ abstract class TokenService<T : TokenType>(
|
||||
@Transactional
|
||||
open fun generate(user: User): Token<T> {
|
||||
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<T : TokenType>(
|
||||
return tokenRepository.save(token)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
open fun generateWithPayload(user: User, payload: Map<String, String>): Token<T> {
|
||||
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<T>? {
|
||||
val token = tokenRepository.findBySecret(secret) ?: return null
|
||||
@@ -39,6 +55,11 @@ abstract class TokenService<T : TokenType>(
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
open fun getPayload(secret: String): Map<String, String>? {
|
||||
return tokenRepository.findBySecret(secret)?.payload
|
||||
}
|
||||
|
||||
@Transactional
|
||||
open fun delete(token: Token<T>) {
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ class EmailConfirmationService(
|
||||
return TokenValidationResult.EXPIRED
|
||||
}
|
||||
|
||||
val user = emailConfirmationToken.user
|
||||
val user = emailConfirmationToken.creator
|
||||
confirmEmail(user)
|
||||
delete(emailConfirmationToken)
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ class PasswordResetService(
|
||||
return TokenValidationResult.EXPIRED
|
||||
}
|
||||
|
||||
val user = passwordResetToken.user
|
||||
val user = passwordResetToken.creator
|
||||
|
||||
userService.updatePassword(user, newPassword)
|
||||
delete(passwordResetToken)
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface UserRepository : JpaRepository<User, Long> {
|
||||
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?
|
||||
|
||||
@@ -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>(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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user