mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-17 08:15:44 +00:00
WIP: Implemented Password reset flow based on user configurable templates
This commit is contained in:
@@ -48,3 +48,4 @@ out/
|
|||||||
/src/main/frontend/generated/
|
/src/main/frontend/generated/
|
||||||
/db/
|
/db/
|
||||||
/logs/
|
/logs/
|
||||||
|
/templates/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
@@ -6,6 +6,7 @@ import Section from "Frontend/components/general/Section";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Chip,
|
||||||
Modal,
|
Modal,
|
||||||
ModalBody,
|
ModalBody,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
@@ -14,15 +15,23 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
useDisclosure
|
useDisclosure
|
||||||
} from "@nextui-org/react";
|
} from "@nextui-org/react";
|
||||||
import {ConfigEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints";
|
import {MessageTemplateEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
|
|
||||||
import {Pencil} from "@phosphor-icons/react";
|
import {Pencil} from "@phosphor-icons/react";
|
||||||
|
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
|
||||||
|
|
||||||
function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||||
|
|
||||||
const {isOpen, onOpen, onOpenChange} = useDisclosure();
|
const {isOpen, onOpen, onOpenChange} = useDisclosure();
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<ConfigEntryDto | null>(null);
|
const [availableTemplates, setAvailableTemplates] = useState<MessageTemplateDto[]>([]);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<MessageTemplateDto | null>(null);
|
||||||
|
const [templateContent, setTemplateContent] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
MessageTemplateEndpoint.getAll().then((response: any) => {
|
||||||
|
setAvailableTemplates(response as MessageTemplateDto[]);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function verifyCredentials(provider: string) {
|
async function verifyCredentials(provider: string) {
|
||||||
const credentials: Record<string, any> = {
|
const credentials: Record<string, any> = {
|
||||||
@@ -41,17 +50,22 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openModal(template: ConfigEntryDto) {
|
async function openModal(template: MessageTemplateDto) {
|
||||||
let templateContent = await ConfigEndpoint.get(template.key);
|
setSelectedTemplate(template);
|
||||||
setSelectedTemplate({
|
|
||||||
...template,
|
let templateContent = await MessageTemplateEndpoint.read(template.key);
|
||||||
value: templateContent
|
|
||||||
});
|
if (templateContent === undefined) {
|
||||||
|
toast.error("Can't read template content");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTemplateContent(templateContent);
|
||||||
onOpen();
|
onOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTemplate(template: ConfigEntryDto) {
|
async function saveTemplate(template: MessageTemplateDto) {
|
||||||
await ConfigEndpoint.set(template.key, template.value);
|
await MessageTemplateEndpoint.save(template.key, templateContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,7 +95,7 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
|||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<Section title="Message Templates"/>
|
<Section title="Message Templates"/>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{getConfigs("notifications.templates").map((template: ConfigEntryDto) =>
|
{availableTemplates.map((template: MessageTemplateDto) =>
|
||||||
<Card className="flex flex-row items-center gap-2 p-4" key={template.key}>
|
<Card className="flex flex-row items-center gap-2 p-4" key={template.key}>
|
||||||
<Button isIconOnly
|
<Button isIconOnly
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -103,18 +117,24 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
|||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<>
|
<>
|
||||||
<ModalHeader
|
<ModalHeader
|
||||||
className="flex flex-col gap-1">Edit {selectedTemplate?.description.toLowerCase()}</ModalHeader>
|
className="flex flex-col gap-1">Edit {selectedTemplate?.name} Template</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<p>Available placeholders:</p>
|
||||||
|
{selectedTemplate?.availablePlaceholders?.map((placeholder) =>
|
||||||
|
<Chip radius="sm"
|
||||||
|
key={placeholder}
|
||||||
|
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "danger"}
|
||||||
|
>{placeholder}</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
size="lg"
|
size="lg"
|
||||||
autoFocus
|
autoFocus
|
||||||
disableAutosize
|
disableAutosize
|
||||||
value={selectedTemplate?.value}
|
value={templateContent}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (selectedTemplate?.key) setSelectedTemplate({
|
setTemplateContent(e.target.value)
|
||||||
...selectedTemplate,
|
|
||||||
value: e.target.value
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
classNames={{
|
classNames={{
|
||||||
input: "resize-y min-h-[500px]"
|
input: "resize-y min-h-[500px]"
|
||||||
@@ -127,7 +147,7 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button color="primary" onPress={async () => {
|
<Button color="primary" onPress={async () => {
|
||||||
if (selectedTemplate) {
|
if (selectedTemplate) {
|
||||||
await saveTemplate(selectedTemplate);
|
await saveTemplate(selectedTemplate,);
|
||||||
toast.success("Template saved")
|
toast.success("Template saved")
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.grimsi.gameyfin.core
|
||||||
|
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes
|
||||||
|
|
||||||
|
class Utils {
|
||||||
|
companion object {
|
||||||
|
fun maskEmail(email: String): String {
|
||||||
|
val regex = """(?:\G(?!^)|(?<=^[^@]{2}|@))[^@](?!\.[^.]+$)""".toRegex()
|
||||||
|
return email.replace(regex, "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBaseUrl(): String {
|
||||||
|
val request = (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes).request
|
||||||
|
val scheme = request.scheme
|
||||||
|
val serverName = request.serverName
|
||||||
|
val serverPort = request.serverPort
|
||||||
|
val contextPath = request.contextPath
|
||||||
|
|
||||||
|
return if (serverPort == 80 || serverPort == 443) {
|
||||||
|
"$scheme://$serverName$contextPath"
|
||||||
|
} else {
|
||||||
|
"$scheme://$serverName:$serverPort$contextPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ class UserInvitationEvent(source: Any) : ApplicationEvent(source)
|
|||||||
|
|
||||||
class UserRegistrationEvent(source: Any) : ApplicationEvent(source)
|
class UserRegistrationEvent(source: Any) : ApplicationEvent(source)
|
||||||
|
|
||||||
class PasswordResetRequestEvent(source: Any, val token: PasswordResetToken) : ApplicationEvent(source)
|
class PasswordResetRequestEvent(source: Any, val token: PasswordResetToken, val baseUrl: String) :
|
||||||
|
ApplicationEvent(source)
|
||||||
|
|
||||||
class GameRequestEvent(source: Any) : ApplicationEvent(source)
|
class GameRequestEvent(source: Any) : ApplicationEvent(source)
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class SsoAuthenticationSuccessHandler(
|
|||||||
// Update user with new SSO data
|
// Update user with new SSO data
|
||||||
matchedUser.username = oidcUser.preferredUsername
|
matchedUser.username = oidcUser.preferredUsername
|
||||||
matchedUser.email = oidcUser.email
|
matchedUser.email = oidcUser.email
|
||||||
matchedUser.email_confirmed = true
|
matchedUser.emailConfirmed = true
|
||||||
matchedUser.oidcProviderId = oidcUser.subject
|
matchedUser.oidcProviderId = oidcUser.subject
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ package de.grimsi.gameyfin.notifications
|
|||||||
|
|
||||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
||||||
import de.grimsi.gameyfin.notifications.providers.AbstractNotificationProvider
|
import de.grimsi.gameyfin.notifications.providers.AbstractNotificationProvider
|
||||||
|
import de.grimsi.gameyfin.notifications.templates.MessageTemplateService
|
||||||
|
import de.grimsi.gameyfin.notifications.templates.MessageTemplates
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.context.ApplicationContext
|
import org.springframework.context.ApplicationContext
|
||||||
import org.springframework.context.event.EventListener
|
import org.springframework.context.event.EventListener
|
||||||
import org.springframework.scheduling.annotation.Async
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
@EnableAsync
|
||||||
@Service
|
@Service
|
||||||
class NotificationService(
|
class NotificationService(
|
||||||
private val applicationContext: ApplicationContext
|
private val applicationContext: ApplicationContext,
|
||||||
|
private val templateService: MessageTemplateService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val log: KLogger = KotlinLogging.logger {}
|
val log: KLogger = KotlinLogging.logger {}
|
||||||
@@ -40,12 +45,16 @@ class NotificationService(
|
|||||||
log.info { "Sending password reset request notification" }
|
log.info { "Sending password reset request notification" }
|
||||||
|
|
||||||
val token = event.token
|
val token = event.token
|
||||||
|
val resetLink = event.baseUrl + "/reset-password?token=${token.token}"
|
||||||
|
val content = templateService.fillMessageTemplate(
|
||||||
|
MessageTemplates.PasswordResetRequest,
|
||||||
|
mapOf("username" to token.user.username, "resetLink" to resetLink)
|
||||||
|
)
|
||||||
|
|
||||||
// TODO: Implement proper email template
|
|
||||||
sendNotification(
|
sendNotification(
|
||||||
token.user.email,
|
token.user.email,
|
||||||
"Password Reset Request",
|
"[Gameyfin] Password Reset Request",
|
||||||
"You have requested a password reset. Your token is ${token.token}"
|
content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.grimsi.gameyfin.notifications.templates
|
||||||
|
|
||||||
|
data class MessageTemplateDto(
|
||||||
|
val key: String,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val availablePlaceholders: List<String>
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.grimsi.gameyfin.notifications.templates
|
||||||
|
|
||||||
|
import com.vaadin.hilla.Endpoint
|
||||||
|
import de.grimsi.gameyfin.core.Roles
|
||||||
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
|
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||||
|
@Endpoint
|
||||||
|
class MessageTemplateEndpoint(
|
||||||
|
private val messageTemplateService: MessageTemplateService
|
||||||
|
) {
|
||||||
|
fun getAll(): List<MessageTemplateDto> {
|
||||||
|
return messageTemplateService.getMessageTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(key: String): MessageTemplates {
|
||||||
|
return messageTemplateService.getMessageTemplate(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun read(key: String): String {
|
||||||
|
return messageTemplateService.getMessageTemplateContent(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(key: String, content: String) {
|
||||||
|
messageTemplateService.setMessageTemplateContent(key, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package de.grimsi.gameyfin.notifications.templates
|
||||||
|
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
import kotlin.io.path.writeText
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class MessageTemplateService {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TEMPLATE_PATH = "templates"
|
||||||
|
private const val TEMPLATE_EXTENSION = "html"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
fun getMessageTemplates(): List<MessageTemplateDto> {
|
||||||
|
log.info { "Getting all message templates" }
|
||||||
|
val messageTemplates = MessageTemplates::class.sealedSubclasses.flatMap { subclass ->
|
||||||
|
subclass.objectInstance?.let { listOf(it) } ?: listOf()
|
||||||
|
}
|
||||||
|
return messageTemplates.map { MessageTemplateDto(it.key, it.name, it.description, it.availablePlaceholders) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMessageTemplate(key: String): MessageTemplates {
|
||||||
|
return MessageTemplates::class.sealedSubclasses
|
||||||
|
.mapNotNull { it.objectInstance }
|
||||||
|
.find { it.key == key }
|
||||||
|
?: throw IllegalArgumentException("Message template with key '$key' not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMessageTemplateContent(key: String): String {
|
||||||
|
log.info { "Reading message template content for key '$key'" }
|
||||||
|
return getOrCreateTemplateFile(key).readText()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fillMessageTemplate(template: MessageTemplates, placeholders: Map<String, String>): String {
|
||||||
|
if (placeholders.keys != template.availablePlaceholders.toSet()) {
|
||||||
|
throw IllegalArgumentException("Placeholders do not match available placeholders for template '${template.key}'")
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = getMessageTemplateContent(template.key)
|
||||||
|
return placeholders.entries.fold(content) { acc, (placeholder, value) ->
|
||||||
|
acc.replace("{$placeholder}", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMessageTemplateContent(key: String, content: String) {
|
||||||
|
log.info { "Saving message template content for key '$key'" }
|
||||||
|
getOrCreateTemplateFile(key).writeText(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateTemplateFile(key: String): Path {
|
||||||
|
val path = Path.of("./", TEMPLATE_PATH, "$key.$TEMPLATE_EXTENSION")
|
||||||
|
if (Files.notExists(path)) {
|
||||||
|
Files.createDirectories(path.parent)
|
||||||
|
Files.createFile(path)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.grimsi.gameyfin.notifications.templates
|
||||||
|
|
||||||
|
sealed class MessageTemplates(
|
||||||
|
val key: String,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val availablePlaceholders: List<String> = emptyList()
|
||||||
|
) {
|
||||||
|
data object UserInvitation : MessageTemplates(
|
||||||
|
"user-invitation",
|
||||||
|
"User Invitation",
|
||||||
|
"Template for the invitation message for new users",
|
||||||
|
listOf("invitationLink")
|
||||||
|
)
|
||||||
|
|
||||||
|
data object Welcome : MessageTemplates(
|
||||||
|
"welcome",
|
||||||
|
"Welcome",
|
||||||
|
"Template for the welcome message for new users",
|
||||||
|
listOf("username")
|
||||||
|
)
|
||||||
|
|
||||||
|
data object PasswordResetRequest : MessageTemplates(
|
||||||
|
"password-reset-request",
|
||||||
|
"Password Reset Request",
|
||||||
|
"Template for the password reset request message",
|
||||||
|
listOf("username", "resetLink")
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.grimsi.gameyfin.users
|
package de.grimsi.gameyfin.users
|
||||||
|
|
||||||
|
import de.grimsi.gameyfin.core.Utils
|
||||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
||||||
import de.grimsi.gameyfin.users.entities.PasswordResetToken
|
import de.grimsi.gameyfin.users.entities.PasswordResetToken
|
||||||
import de.grimsi.gameyfin.users.entities.User
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
@@ -29,16 +30,22 @@ class PasswordResetService(
|
|||||||
|
|
||||||
private val secureRandom = SecureRandom()
|
private val secureRandom = SecureRandom()
|
||||||
|
|
||||||
|
private val PasswordResetToken.isExpired: Boolean
|
||||||
|
get() = createdOn?.plus(TOKEN_EXPIRATION.toJavaDuration())!!.isBefore(Instant.now())
|
||||||
|
|
||||||
|
private val baseUrl: String
|
||||||
|
get() = Utils.getBaseUrl()
|
||||||
|
|
||||||
fun requestPasswordReset(email: String) {
|
fun requestPasswordReset(email: String) {
|
||||||
|
|
||||||
log.info { "Initiating password reset request for '${maskEmail(email)}'" }
|
log.info { "Initiating password reset request for '${Utils.maskEmail(email)}'" }
|
||||||
|
|
||||||
val user = userService.getByEmail(email)
|
val user = userService.getByEmail(email)
|
||||||
|
|
||||||
// A user can only reset its password if its email is confirmed, and it's not an SSO user
|
// A user can only reset its password if its email is confirmed, and it's not an SSO user
|
||||||
if (user != null && user.email_confirmed && user.oidcProviderId == null) {
|
if (user != null && user.emailConfirmed && user.oidcProviderId == null) {
|
||||||
val token = createPasswordResetToken(user)
|
val token = createPasswordResetToken(user)
|
||||||
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token))
|
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate a delay to prevent timing attacks
|
// Simulate a delay to prevent timing attacks
|
||||||
@@ -73,12 +80,4 @@ class PasswordResetService(
|
|||||||
passwordResetTokenRepository.delete(passwordResetToken)
|
passwordResetTokenRepository.delete(passwordResetToken)
|
||||||
sessionService.logoutAllSessions(user)
|
sessionService.logoutAllSessions(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun maskEmail(email: String): String {
|
|
||||||
val regex = """(?:\G(?!^)|(?<=^[^@]{2}|@))[^@](?!\.[^.]+$)""".toRegex()
|
|
||||||
return email.replace(regex, "*")
|
|
||||||
}
|
|
||||||
|
|
||||||
private val PasswordResetToken.isExpired: Boolean
|
|
||||||
get() = createdOn?.plus(TOKEN_EXPIRATION.toJavaDuration())!!.isBefore(Instant.now())
|
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ class UserService(
|
|||||||
return UserInfoDto(
|
return UserInfoDto(
|
||||||
username = user.username,
|
username = user.username,
|
||||||
email = user.email,
|
email = user.email,
|
||||||
emailConfirmed = user.email_confirmed,
|
emailConfirmed = user.emailConfirmed,
|
||||||
managedBySso = user.oidcProviderId != null,
|
managedBySso = user.oidcProviderId != null,
|
||||||
roles = user.roles.map { r -> r.rolename }
|
roles = user.roles.map { r -> r.rolename }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ class User(
|
|||||||
@Convert(converter = EncryptionConverter::class)
|
@Convert(converter = EncryptionConverter::class)
|
||||||
var email: String,
|
var email: String,
|
||||||
|
|
||||||
var email_confirmed: Boolean = false,
|
// TODO: Add email confirmation
|
||||||
|
var emailConfirmed: Boolean = true,
|
||||||
|
|
||||||
var enabled: Boolean = true,
|
var enabled: Boolean = true,
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ class User(
|
|||||||
constructor(oidcUser: OidcUser) : this(
|
constructor(oidcUser: OidcUser) : this(
|
||||||
username = oidcUser.preferredUsername,
|
username = oidcUser.preferredUsername,
|
||||||
email = oidcUser.email,
|
email = oidcUser.email,
|
||||||
email_confirmed = true,
|
emailConfirmed = true,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
oidcProviderId = oidcUser.subject
|
oidcProviderId = oidcUser.subject
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user