Implemented basic user management

Smaller refactorings
This commit is contained in:
grimsi
2024-09-15 13:47:36 +02:00
parent 110eb3dd5a
commit 791fd71795
10 changed files with 217 additions and 124 deletions
@@ -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<any>();
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() {
</div>
<div className="flex flex-row flex-1 justify-between gap-16">
<div className="flex flex-col basis-1/4 mt-8 items-center">
<Avatar showFallback
src={`/images/avatar?username=${auth.state.user?.username}`}
className="size-40 m-4"></Avatar>
<AvatarUpload upload="/avatar/upload" remove="/avatar/delete" accept="image/*"/>
<div className="flex flex-col basis-1/4 mt-8 gap-4">
<div className="flex flex-row justify-center">
<Avatar showFallback
src={`/images/avatar?username=${auth.state.user?.username}`}
className="size-40 m-4 flex flex-row">
</Avatar>
</div>
<div className="flex flex-row gap-2">
<NextUiInput type="file" accept="image/*" onChange={onFileSelected}/>
<Button onClick={() => uploadAvatar(avatar)} isDisabled={avatar == null}
color="success">Upload</Button>
<Tooltip content="Remove your current avatar">
<Button onClick={removeAvatar} isIconOnly color="danger"><Trash/></Button>
</Tooltip>
</div>
</div>
<div className="flex flex-col flex-grow">
@@ -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<UserInfoDto[]>([]);
@@ -28,23 +27,11 @@ function UserManagementLayout({getConfig, formik}: any) {
<Section title="Users"/>
<div className="grid grid-cols-300px gap-4">
{users.map((user) => <UserCard user={user} key={user.username}/>)}
{users.map((user) => <UserManagementCard user={user} key={user.username}/>)}
</div>
</div>
)
;
}
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);
export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users");
@@ -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<any>();
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 (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2">
<Input type="file" accept={accept} onChange={onFileSelected}/>
<Button onClick={uploadAvatar} isDisabled={avatar == null} color="success">Upload</Button>
<Tooltip content="Remove your current avatar">
<Button onClick={removeAvatar} isIconOnly color="danger"><Trash/></Button>
</Tooltip>
</div>
</div>
);
};
@@ -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 (
<Card className="flex flex-row items-center gap-4 p-2">
<Avatar showFallback
name={user.username?.charAt(0)}
src={`/images/avatar?username=${user?.username}`}
classNames={{
base: "gradient-primary size-20",
icon: "text-background/80",
name: "text-background/80 text-5xl -mt-1",
}}></Avatar>
<div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p>
{user.roles?.map((role) =>
<Chip key={role} size="sm" radius="sm"
className={`text-xs bg-${roleToColor(role!)}-500`}>{roleToRoleName(role!)}</Chip>)}
</div>
</Card>
)
}
@@ -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<string[]>([]);
const auth = useAuth();
const [confirmUsername, setConfirmUsername] = useState<string>("");
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 (
<Card className="flex flex-row justify-between p-2">
<div className="flex flex-row items-center gap-4">
<Avatar showFallback
name={user.username?.charAt(0)}
src={`/images/avatar?username=${user?.username}`}
classNames={{
base: "gradient-primary size-20",
icon: "text-background/80",
name: "text-background/80 text-5xl -mt-1",
}}></Avatar>
<div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p>
{user.roles?.map((role) =>
<Chip key={role} size="sm" radius="sm"
className={`text-xs bg-${roleToColor(role!)}-500`}>{roleToRoleName(role!)}</Chip>)}
</div>
</div>
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger>
<DotsThreeVertical/>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions" disabledKeys={disabledKeys}>
<DropdownItem key="removeAvatar" onPress={() => AvatarEndpoint.removeAvatarByName(user.username!)}>
Remove avatar
</DropdownItem>
<DropdownItem key="delete" className="text-danger" color="danger"
onPress={onOpen}>
Delete user
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Confirm user deletion</ModalHeader>
<ModalBody>
<p>
Confirm deletion of user <Code>{user.username}</Code> by entering the username
below
</p>
<Input onChange={(e) => setConfirmUsername(e.target.value)}/>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="danger" onPress={deleteUser}
isDisabled={confirmUsername != user.username}>
Confirm deletion
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</Card>
)
}
@@ -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});
}
}
+3
View File
@@ -0,0 +1,3 @@
import * as AvatarEndpoint from './AvatarEndpoint'
export {AvatarEndpoint}
+12
View File
@@ -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<Response> {
return await fetch(url, {
headers: {
"X-CSRF-Token": getCsrfToken()
},
credentials: "same-origin",
method: method,
body: body
});
}
@@ -52,7 +52,7 @@ class UserEndpoint(
}
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
fun deleteUser(username: String) {
fun deleteUserByName(username: String) {
userService.deleteUser(username)
}
@@ -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(