From 791fd71795d8fde7d07ed74b9f2c2c10e07af8ea Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Sun, 15 Sep 2024 13:47:36 +0200 Subject: [PATCH] Implemented basic user management Smaller refactorings --- .../administration/ProfileManagement.tsx | 32 +++-- .../administration/UserManagement.tsx | 19 +-- .../components/general/AvatarUpload.tsx | 74 ----------- .../frontend/components/general/UserCard.tsx | 25 ---- .../components/general/UserManagementCard.tsx | 125 ++++++++++++++++++ src/main/frontend/endpoints/AvatarEndpoint.ts | 41 ++++++ src/main/frontend/endpoints/endpoints.ts | 3 + src/main/frontend/util/utils.ts | 12 ++ .../de/grimsi/gameyfin/users/UserEndpoint.kt | 2 +- .../gameyfin/users/avatar/AvatarController.kt | 8 ++ 10 files changed, 217 insertions(+), 124 deletions(-) delete mode 100644 src/main/frontend/components/general/AvatarUpload.tsx delete mode 100644 src/main/frontend/components/general/UserCard.tsx create mode 100644 src/main/frontend/components/general/UserManagementCard.tsx create mode 100644 src/main/frontend/endpoints/AvatarEndpoint.ts create mode 100644 src/main/frontend/endpoints/endpoints.ts diff --git a/src/main/frontend/components/administration/ProfileManagement.tsx b/src/main/frontend/components/administration/ProfileManagement.tsx index b317c15..ffa5940 100644 --- a/src/main/frontend/components/administration/ProfileManagement.tsx +++ b/src/main/frontend/components/administration/ProfileManagement.tsx @@ -1,8 +1,8 @@ import Section from "Frontend/components/general/Section"; import Input from "Frontend/components/general/Input"; +import {Avatar, Button, Input as NextUiInput, Tooltip} from "@nextui-org/react"; import {Form, Formik} from "formik"; -import {Avatar, Button} from "@nextui-org/react"; -import {Check, Info} from "@phosphor-icons/react"; +import {Check, Info, Trash} from "@phosphor-icons/react"; import React, {useEffect, useState} from "react"; import {useAuth} from "Frontend/util/auth"; import * as Yup from "yup"; @@ -10,11 +10,12 @@ import UserUpdateDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserU import {UserEndpoint} from "Frontend/generated/endpoints"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; import {toast} from "sonner"; -import AvatarUpload from "Frontend/components/general/AvatarUpload"; +import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint"; export default function ProfileManagement() { const [configSaved, setConfigSaved] = useState(false); const auth = useAuth(); + const [avatar, setAvatar] = useState(); useEffect(() => { if (configSaved) { @@ -22,6 +23,11 @@ export default function ProfileManagement() { } }, [configSaved]) + + function onFileSelected(event: any) { + setAvatar(event.target.files[0]); + } + async function handleSubmit(values: any) { const userUpdate: UserUpdateDto = { username: values.username, @@ -91,11 +97,21 @@ export default function ProfileManagement() {
-
- - +
+
+ + +
+
+ + + + + +
diff --git a/src/main/frontend/components/administration/UserManagement.tsx b/src/main/frontend/components/administration/UserManagement.tsx index a5c1286..f13cc53 100644 --- a/src/main/frontend/components/administration/UserManagement.tsx +++ b/src/main/frontend/components/administration/UserManagement.tsx @@ -2,10 +2,9 @@ import React, {useEffect, useState} from "react"; import ConfigFormField from "Frontend/components/administration/ConfigFormField"; import withConfigPage from "Frontend/components/administration/withConfigPage"; import Section from "Frontend/components/general/Section"; -import * as Yup from "yup"; import {UserEndpoint} from "Frontend/generated/endpoints"; import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto"; -import {UserCard} from "Frontend/components/general/UserCard"; +import {UserManagementCard} from "Frontend/components/general/UserManagementCard"; function UserManagementLayout({getConfig, formik}: any) { const [users, setUsers] = useState([]); @@ -28,23 +27,11 @@ function UserManagementLayout({getConfig, formik}: any) {
- {users.map((user) => )} + {users.map((user) => )}
-
) ; } -const validationSchema = Yup.object({ - library: Yup.object({ - metadata: Yup.object({ - update: Yup.object({ - // @ts-ignore - schedule: Yup.string().cron() - }) - }) - }) -}); - -export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users", validationSchema); \ No newline at end of file +export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users"); \ No newline at end of file diff --git a/src/main/frontend/components/general/AvatarUpload.tsx b/src/main/frontend/components/general/AvatarUpload.tsx deleted file mode 100644 index 6f11b8a..0000000 --- a/src/main/frontend/components/general/AvatarUpload.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import {toast} from "sonner"; -import {getCsrfToken} from "Frontend/util/auth"; -import {Button, Input, Tooltip} from "@nextui-org/react"; -import {useState} from "react"; -import {Trash} from "@phosphor-icons/react"; - -export default function AvatarUpload({upload, remove, accept}: { upload: string, remove: string, accept: string }) { - - const [avatar, setAvatar] = useState(); - - function onFileSelected(event: any) { - setAvatar(event.target.files[0]); - } - - async function uploadAvatar() { - const formData = new FormData(); - - formData.append("file", avatar); - try { - const response = await fetch(upload, { - headers: { - "X-CSRF-Token": getCsrfToken() - }, - method: "POST", - credentials: "same-origin", - body: formData - }); - - const result = await response.text(); - - if (response.ok) { - window.location.reload(); - } else { - toast.error("Error uploading avatar", {description: result}); - } - } catch (error: any) { - toast.error("Error uploading avatar", {description: error.message}) - } - } - - async function removeAvatar() { - try { - const response = await fetch(remove, { - headers: { - "X-CSRF-Token": getCsrfToken() - }, - method: "POST", - credentials: "same-origin" - }); - - const result = await response.text(); - - if (response.ok) { - window.location.reload(); - } else { - toast.error("Error removing avatar", {description: result}); - } - } catch (error: any) { - toast.error("Error removing avatar", {description: error.message}) - } - } - - return ( -
-
- - - - - -
-
- ); -}; \ No newline at end of file diff --git a/src/main/frontend/components/general/UserCard.tsx b/src/main/frontend/components/general/UserCard.tsx deleted file mode 100644 index cc4760b..0000000 --- a/src/main/frontend/components/general/UserCard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto"; -import {Avatar, Card, Chip} from "@nextui-org/react"; -import {roleToColor, roleToRoleName} from "Frontend/util/utils"; - -export function UserCard({user}: { user: UserInfoDto }) { - return ( - - -
-

{user.username}

-

{user.email}

- {user.roles?.map((role) => - {roleToRoleName(role!)})} -
-
- ) -} \ No newline at end of file diff --git a/src/main/frontend/components/general/UserManagementCard.tsx b/src/main/frontend/components/general/UserManagementCard.tsx new file mode 100644 index 0000000..120e8a4 --- /dev/null +++ b/src/main/frontend/components/general/UserManagementCard.tsx @@ -0,0 +1,125 @@ +import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto"; +import { + Avatar, + Button, + Card, + Chip, + Code, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownTrigger, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + useDisclosure +} from "@nextui-org/react"; +import {roleToColor, roleToRoleName} from "Frontend/util/utils"; +import {DotsThreeVertical} from "@phosphor-icons/react"; +import {useAuth} from "Frontend/util/auth"; +import {useEffect, useState} from "react"; +import {UserEndpoint} from "Frontend/generated/endpoints"; +import {AvatarEndpoint} from "Frontend/endpoints/endpoints"; + +export function UserManagementCard({user}: { user: UserInfoDto }) { + const {isOpen, onOpen, onOpenChange} = useDisclosure(); + const [disabledKeys, setDisabledKeys] = useState([]); + const auth = useAuth(); + const [confirmUsername, setConfirmUsername] = useState(""); + + useEffect(() => { + if (!canUserBeDeleted()) setDisabledKeys(["delete"]) + }, []); + + useEffect(() => { + setConfirmUsername(""); + }, [isOpen]); + + function canUserBeDeleted(): Boolean { + // User should not be able to delete himself through this menu (can be done via "My profile") + if (auth.state.user?.username === user.username) return false; + + // User should not be able to delete the SUPERADMIN + if (user.roles?.includes("ROLE_SUPERADMIN")) return false; + + // Superadmins can delete anyone excluding themselves (and other superadmins if there are any) + if (auth.state.user?.roles?.includes("ROLE_SUPERADMIN")) return true; + + // Admins should be only allowed to delete other users, not other admins + if (user.roles?.includes("ROLE_ADMIN")) return false; + + return true; + } + + async function deleteUser() { + await UserEndpoint.deleteUserByName(user.username); + window.location.reload(); + } + + return ( + +
+ +
+

{user.username}

+

{user.email}

+ {user.roles?.map((role) => + {roleToRoleName(role!)})} +
+
+ + + + + + + AvatarEndpoint.removeAvatarByName(user.username!)}> + Remove avatar + + + Delete user + + + + + + + {(onClose) => ( + <> + Confirm user deletion + +

+ Confirm deletion of user {user.username} by entering the username + below +

+ setConfirmUsername(e.target.value)}/> +
+ + + + + + )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/main/frontend/endpoints/AvatarEndpoint.ts b/src/main/frontend/endpoints/AvatarEndpoint.ts new file mode 100644 index 0000000..9d484bb --- /dev/null +++ b/src/main/frontend/endpoints/AvatarEndpoint.ts @@ -0,0 +1,41 @@ +import {fetchWithAuth} from "Frontend/util/utils"; +import {toast} from "sonner"; + +export async function uploadAvatar(avatar: any) { + const formData = new FormData(); + formData.append("file", avatar); + + const response = await fetchWithAuth("avatar/upload", formData); + + const result = await response.text(); + + if (response.ok) { + window.location.reload(); + } else { + toast.error("Error uploading avatar", {description: result}); + } +} + +export async function removeAvatar() { + const response = await fetchWithAuth("avatar/delete") + + const result = await response.text(); + + if (response.ok) { + window.location.reload(); + } else { + toast.error("Error removing avatar", {description: result}); + } +} + +export async function removeAvatarByName(name: string) { + const response = await fetchWithAuth("avatar/deleteByName?" + new URLSearchParams({name: name})) + + const result = await response.text(); + + if (response.ok) { + window.location.reload(); + } else { + toast.error("Error removing avatar", {description: result}); + } +} \ No newline at end of file diff --git a/src/main/frontend/endpoints/endpoints.ts b/src/main/frontend/endpoints/endpoints.ts new file mode 100644 index 0000000..ca60cf3 --- /dev/null +++ b/src/main/frontend/endpoints/endpoints.ts @@ -0,0 +1,3 @@ +import * as AvatarEndpoint from './AvatarEndpoint' + +export {AvatarEndpoint} \ No newline at end of file diff --git a/src/main/frontend/util/utils.ts b/src/main/frontend/util/utils.ts index 53ec83d..edc40a4 100644 --- a/src/main/frontend/util/utils.ts +++ b/src/main/frontend/util/utils.ts @@ -1,5 +1,6 @@ import {type ClassValue, clsx} from "clsx" import {twMerge} from "tailwind-merge" +import {getCsrfToken} from "Frontend/util/auth"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -35,4 +36,15 @@ export function roleToColor(role: string) { default: return "gray"; } +} + +export async function fetchWithAuth(url: string, body: any = null, method = "POST"): Promise { + return await fetch(url, { + headers: { + "X-CSRF-Token": getCsrfToken() + }, + credentials: "same-origin", + method: method, + body: body + }); } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt index 3e29806..8c0c62f 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt @@ -52,7 +52,7 @@ class UserEndpoint( } @RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN) - fun deleteUser(username: String) { + fun deleteUserByName(username: String) { userService.deleteUser(username) } diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt b/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt index 5010c24..e11f158 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt @@ -1,7 +1,9 @@ package de.grimsi.gameyfin.users.avatar +import de.grimsi.gameyfin.meta.Roles import de.grimsi.gameyfin.users.UserService import jakarta.annotation.security.PermitAll +import jakarta.annotation.security.RolesAllowed import jakarta.servlet.http.HttpServletResponse import org.springframework.core.io.InputStreamResource import org.springframework.http.HttpHeaders @@ -33,6 +35,12 @@ class AvatarController( userService.deleteAvatar(auth.name) } + @RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN) + @PostMapping("/avatar/deleteByName") + fun deleteAvatarByName(@RequestParam("name") name: String) { + userService.deleteAvatar(name) + } + @PermitAll @GetMapping("/images/avatar") fun getAvatar(