mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 08:15:37 +00:00
Implemented hierarchical roles
Finished password reset flow in frontend
This commit is contained in:
@@ -14,6 +14,7 @@ import {AdministrationView} from "Frontend/views/AdministrationView";
|
||||
import {ProfileView} from "Frontend/views/ProfileView";
|
||||
import {NotificationManagement} from "Frontend/components/administration/NotificationManagement";
|
||||
import {LogManagement} from "Frontend/components/administration/LogManagement";
|
||||
import PasswordResetView from "Frontend/views/PasswordResetView";
|
||||
|
||||
export const routes = protectRoutes([
|
||||
{
|
||||
@@ -53,6 +54,9 @@ export const routes = protectRoutes([
|
||||
},
|
||||
{
|
||||
path: '/setup', element: <SetupView/>, handle: {requiresLogin: false}
|
||||
},
|
||||
{
|
||||
path: '/reset-password', element: <PasswordResetView/>, handle: {requiresLogin: false}
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import {useLayoutEffect, useState} from "react";
|
||||
import {XCircle} from "@phosphor-icons/react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {WarningCircle, XCircle} from "@phosphor-icons/react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "@nextui-org/react";
|
||||
import {Alert, AlertDescription, AlertTitle} from "Frontend/@/components/ui/alert";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||
import {NotificationEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export default function LoginView() {
|
||||
@@ -28,12 +28,17 @@ export default function LoginView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [username, setUsername] = useState<string>();
|
||||
const [password, setPassword] = useState<string>();
|
||||
const [canResetPassword, setCanResetPassword] = useState(false);
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [resetEmail, setResetEmail] = useState<string>();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
NotificationEndpoint.isEnabled().then(setCanResetPassword);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.user) {
|
||||
const path = url ? new URL(url, document.baseURI).pathname : '/'
|
||||
navigate(path, {replace: true});
|
||||
@@ -119,28 +124,39 @@ export default function LoginView() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input
|
||||
onChange={(event: any) => {
|
||||
setResetEmail(event.target.value);
|
||||
}}
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
/>
|
||||
{canResetPassword ?
|
||||
<Input
|
||||
onChange={(event: any) => {
|
||||
setResetEmail(event.target.value);
|
||||
}}
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
/> :
|
||||
<div className="flex flex-row items-center gap-4 text-warning">
|
||||
<WarningCircle size={40}/>
|
||||
<p>
|
||||
Password self-service is disabled.<br/>
|
||||
To reset your password please contact your administrator.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onPress={async () => {
|
||||
await resetPassword();
|
||||
onClose();
|
||||
}}>
|
||||
<Button color="primary"
|
||||
isDisabled={!canResetPassword}
|
||||
onPress={async () => {
|
||||
await resetPassword();
|
||||
onClose();
|
||||
}}>
|
||||
Send request
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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 {PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Warning} from "@phosphor-icons/react";
|
||||
import {toast} from "sonner";
|
||||
import PasswordResetResult from "Frontend/generated/de/grimsi/gameyfin/users/dto/PasswordResetResult";
|
||||
|
||||
export default function PasswordResetView() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [token, setToken] = useState<string>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
let token = searchParams.get("token");
|
||||
if (token) setToken(token);
|
||||
}, [searchParams]);
|
||||
|
||||
async function resetPassword(values: any) {
|
||||
let token = searchParams.get("token") as string;
|
||||
let result = await PasswordResetEndpoint.resetPassword(token, values.password) as PasswordResetResult;
|
||||
|
||||
switch (result) {
|
||||
case PasswordResetResult.SUCCESS:
|
||||
toast.success("Password reset successfully");
|
||||
navigate("/", {replace: true});
|
||||
break;
|
||||
case PasswordResetResult.EXPIRED_TOKEN:
|
||||
toast.error("Token is expired");
|
||||
break;
|
||||
case PasswordResetResult.INVALID_TOKEN:
|
||||
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
|
||||
initialValues={{
|
||||
password: "",
|
||||
passwordRepeat: ""
|
||||
}}
|
||||
validationSchema={Yup.object({
|
||||
password: Yup.string()
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.required('Required'),
|
||||
passwordRepeat: Yup.string()
|
||||
.equals([Yup.ref('password')], 'Passwords do not match')
|
||||
.required('Required')
|
||||
})}
|
||||
onSubmit={resetPassword}>
|
||||
{(formik: { values: any; isSubmitting: any; isValid: boolean; }) => (
|
||||
<Form>
|
||||
<p className="text-xl text-center mb-8">Reset your password</p>
|
||||
<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 ? "" : "Reset password"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -88,8 +88,8 @@ function SetupView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex size-full gradient-primary">
|
||||
<Card className="w-3/4 h-3/4 min-w-[500px] m-auto p-8">
|
||||
<div className="flex flex-row flex-grow items-center justify-center gradient-primary">
|
||||
<Card className="w-3/4 h-3/4 min-w-[500px] p-8">
|
||||
<Wizard
|
||||
initialValues={{username: '', email: '', password: '', passwordRepeat: ''}}
|
||||
onSubmit={
|
||||
|
||||
@@ -8,7 +8,7 @@ import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
class ConfigEndpoint(
|
||||
private val config: ConfigService
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.grimsi.gameyfin.core.security
|
||||
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.access.hierarchicalroles.RoleHierarchy
|
||||
import org.springframework.security.access.hierarchicalroles.RoleHierarchyAuthoritiesMapper
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
|
||||
@Configuration
|
||||
class AuthenticationProviderConfig {
|
||||
@Bean
|
||||
fun hierarchicalUserAuthenticationProvider(
|
||||
userService: UserService,
|
||||
roleHierarchy: RoleHierarchy,
|
||||
passwordEncoder: PasswordEncoder
|
||||
): DaoAuthenticationProvider {
|
||||
val provider = DaoAuthenticationProvider()
|
||||
provider.setUserDetailsService(userService)
|
||||
provider.setPasswordEncoder(passwordEncoder)
|
||||
provider.setAuthoritiesMapper(RoleHierarchyAuthoritiesMapper(roleHierarchy))
|
||||
return provider
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.grimsi.gameyfin.core.security
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.access.hierarchicalroles.RoleHierarchy
|
||||
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl
|
||||
|
||||
@Configuration
|
||||
class RoleHierarchyConfig {
|
||||
@Bean
|
||||
fun roleHierarchy(): RoleHierarchy {
|
||||
return RoleHierarchyImpl.fromHierarchy(
|
||||
"""ROLE_SUPERADMIN > ROLE_ADMIN
|
||||
|ROLE_ADMIN > ROLE_USER""".trimMargin()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class SecurityConfig(
|
||||
// Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher
|
||||
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
|
||||
auth.requestMatchers("/setup").permitAll()
|
||||
.requestMatchers("/reset-password").permitAll()
|
||||
.requestMatchers("/public/**").permitAll()
|
||||
.requestMatchers("/images/**").permitAll()
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package de.grimsi.gameyfin.libraries
|
||||
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.libraries.entities.Library
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.libraries.entities.Library
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@Endpoint
|
||||
class LibraryEndpoint(
|
||||
private val libraryService: LibraryService
|
||||
) {
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
fun getAllLibraries(): Collection<Library> {
|
||||
return libraryService.getAllLibraries()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import jakarta.annotation.security.RolesAllowed
|
||||
import reactor.core.publisher.Flux
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
class LogEndpoint(
|
||||
private val logService: LogService
|
||||
) {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package de.grimsi.gameyfin.notifications
|
||||
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
class NotificationEndpoint(
|
||||
private val notificationService: NotificationService
|
||||
) {
|
||||
|
||||
@AnonymousAllowed
|
||||
fun isEnabled(): Boolean {
|
||||
return notificationService.enabled
|
||||
}
|
||||
|
||||
fun verifyCredentials(provider: String, credentials: Map<String, Any>): Boolean {
|
||||
return notificationService.testCredentials(provider, credentials)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ class NotificationService(
|
||||
@Async
|
||||
@EventListener(PasswordResetRequestEvent::class)
|
||||
fun onPasswordResetRequest(event: PasswordResetRequestEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send password reset message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending password reset request notification" }
|
||||
|
||||
val token = event.token
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@Endpoint
|
||||
class MessageTemplateEndpoint(
|
||||
private val messageTemplateService: MessageTemplateService
|
||||
|
||||
@@ -9,7 +9,7 @@ class SystemEndpoint(
|
||||
private val systemService: SystemService
|
||||
) {
|
||||
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
fun restart() {
|
||||
systemService.restart()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package de.grimsi.gameyfin.users
|
||||
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.users.dto.PasswordResetResult
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@Endpoint
|
||||
@AnonymousAllowed
|
||||
@@ -11,9 +14,16 @@ class PasswordResetEndpoint(
|
||||
|
||||
fun requestPasswordReset(email: String) {
|
||||
passwordResetService.requestPasswordReset(email)
|
||||
|
||||
// No return value to prevent enumeration attacks
|
||||
}
|
||||
|
||||
fun resetPassword(token: String, newPassword: String) {
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
fun createPasswordResetTokenForUser(username: String): String {
|
||||
return passwordResetService.createPasswordResetToken(username)
|
||||
}
|
||||
|
||||
fun resetPassword(token: String, newPassword: String): PasswordResetResult {
|
||||
return passwordResetService.resetPassword(token, newPassword)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package de.grimsi.gameyfin.users
|
||||
|
||||
import de.grimsi.gameyfin.core.Utils
|
||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
||||
import de.grimsi.gameyfin.notifications.NotificationService
|
||||
import de.grimsi.gameyfin.users.dto.PasswordResetResult
|
||||
import de.grimsi.gameyfin.users.entities.PasswordResetToken
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import de.grimsi.gameyfin.users.persistence.PasswordResetTokenRepository
|
||||
@@ -18,6 +20,7 @@ import kotlin.time.toJavaDuration
|
||||
class PasswordResetService(
|
||||
private val userService: UserService,
|
||||
private val sessionService: SessionService,
|
||||
private val notificationService: NotificationService,
|
||||
private val eventPublisher: ApplicationEventPublisher,
|
||||
private val passwordResetTokenRepository: PasswordResetTokenRepository
|
||||
) {
|
||||
@@ -36,23 +39,46 @@ class PasswordResetService(
|
||||
private val baseUrl: String
|
||||
get() = Utils.getBaseUrl()
|
||||
|
||||
/**
|
||||
* Users can request a password reset when the following conditions are met:
|
||||
* - The user has confirmed their email address
|
||||
* - The user is not managed externally
|
||||
*/
|
||||
fun requestPasswordReset(email: String) {
|
||||
|
||||
log.info { "Initiating password reset request for '${Utils.maskEmail(email)}'" }
|
||||
val maskedEmail = Utils.maskEmail(email)
|
||||
|
||||
log.info { "Initiating password reset request for '${maskedEmail}'" }
|
||||
|
||||
val user = userService.getByEmail(email)
|
||||
|
||||
// A user can only reset its password if its email is confirmed, and it's not an SSO user
|
||||
if (user != null && user.emailConfirmed && user.oidcProviderId == null) {
|
||||
val token = createPasswordResetToken(user)
|
||||
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
|
||||
if (user == null) {
|
||||
log.error { "No user with email '${maskedEmail}' found" }
|
||||
return
|
||||
}
|
||||
|
||||
if (!user.emailConfirmed) {
|
||||
log.error { "User with email '${maskedEmail}' has not confirmed their email address" }
|
||||
return
|
||||
}
|
||||
|
||||
if (user.oidcProviderId != null) {
|
||||
log.error { "User with email '${maskedEmail}' is managed externally" }
|
||||
return
|
||||
}
|
||||
|
||||
val token = createPasswordResetToken(user)
|
||||
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
|
||||
|
||||
// Simulate a delay to prevent timing attacks
|
||||
Thread.sleep(secureRandom.nextLong(1024))
|
||||
}
|
||||
|
||||
fun createPasswordResetToken(user: User): PasswordResetToken {
|
||||
if (user.oidcProviderId != null) {
|
||||
throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally")
|
||||
}
|
||||
|
||||
val token = PasswordResetToken(
|
||||
user = user,
|
||||
token = UUID.randomUUID().toString()
|
||||
@@ -65,13 +91,35 @@ class PasswordResetService(
|
||||
return passwordResetTokenRepository.save(token)
|
||||
}
|
||||
|
||||
fun resetPassword(token: String, newPassword: String) {
|
||||
/**
|
||||
* Admins should be able to create password reset tokens for users when the following conditions are met:
|
||||
* - E-Mail notifications are not enabled
|
||||
* - The user has no confirmed email address
|
||||
* - The user is not managed externally
|
||||
*/
|
||||
fun createPasswordResetToken(username: String): String {
|
||||
if (notificationService.enabled) {
|
||||
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
|
||||
}
|
||||
|
||||
val user = userService.getByUsername(username)
|
||||
?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist")
|
||||
|
||||
if (user.emailConfirmed == true) {
|
||||
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
|
||||
}
|
||||
|
||||
return createPasswordResetToken(user).token
|
||||
}
|
||||
|
||||
|
||||
fun resetPassword(token: String, newPassword: String): PasswordResetResult {
|
||||
val passwordResetToken =
|
||||
passwordResetTokenRepository.findByToken(token)
|
||||
?: throw IllegalArgumentException("Token not found")
|
||||
?: return PasswordResetResult.INVALID_TOKEN
|
||||
|
||||
if (passwordResetToken.isExpired) {
|
||||
throw IllegalStateException("Token is expired")
|
||||
return PasswordResetResult.EXPIRED_TOKEN
|
||||
}
|
||||
|
||||
val user = passwordResetToken.user
|
||||
@@ -79,5 +127,6 @@ class PasswordResetService(
|
||||
userService.updatePassword(user, newPassword)
|
||||
passwordResetTokenRepository.delete(passwordResetToken)
|
||||
sessionService.logoutAllSessions(user)
|
||||
return PasswordResetResult.SUCCESS
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ class UserEndpoint(
|
||||
return userService.getUserInfo(auth)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
fun getAllUsers(): List<UserInfoDto> {
|
||||
return userService.getAllUsers()
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class UserEndpoint(
|
||||
userService.updateUser(auth.name, updates)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
fun updateUserByName(username: String, updates: UserUpdateDto) {
|
||||
userService.updateUser(username, updates)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class UserEndpoint(
|
||||
userService.deleteUser(auth.name)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
fun deleteUserByName(username: String) {
|
||||
userService.deleteUser(username)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class AvatarController(
|
||||
userService.deleteAvatar(auth.name)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
@PostMapping("/avatar/deleteByName")
|
||||
fun deleteAvatarByName(@RequestParam("name") name: String) {
|
||||
userService.deleteAvatar(name)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.grimsi.gameyfin.users.dto
|
||||
|
||||
enum class PasswordResetResult() {
|
||||
SUCCESS, INVALID_TOKEN, EXPIRED_TOKEN
|
||||
}
|
||||
Reference in New Issue
Block a user