mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Implemented basic user management
Smaller refactorings
This commit is contained in:
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as AvatarEndpoint from './AvatarEndpoint'
|
||||
|
||||
export {AvatarEndpoint}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user