mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 00:30:02 +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 Section from "Frontend/components/general/Section";
|
||||||
import Input from "Frontend/components/general/Input";
|
import Input from "Frontend/components/general/Input";
|
||||||
|
import {Avatar, Button, Input as NextUiInput, Tooltip} from "@nextui-org/react";
|
||||||
import {Form, Formik} from "formik";
|
import {Form, Formik} from "formik";
|
||||||
import {Avatar, Button} from "@nextui-org/react";
|
import {Check, Info, Trash} from "@phosphor-icons/react";
|
||||||
import {Check, Info} from "@phosphor-icons/react";
|
|
||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {useAuth} from "Frontend/util/auth";
|
import {useAuth} from "Frontend/util/auth";
|
||||||
import * as Yup from "yup";
|
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 {UserEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import AvatarUpload from "Frontend/components/general/AvatarUpload";
|
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
|
||||||
|
|
||||||
export default function ProfileManagement() {
|
export default function ProfileManagement() {
|
||||||
const [configSaved, setConfigSaved] = useState(false);
|
const [configSaved, setConfigSaved] = useState(false);
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const [avatar, setAvatar] = useState<any>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (configSaved) {
|
if (configSaved) {
|
||||||
@@ -22,6 +23,11 @@ export default function ProfileManagement() {
|
|||||||
}
|
}
|
||||||
}, [configSaved])
|
}, [configSaved])
|
||||||
|
|
||||||
|
|
||||||
|
function onFileSelected(event: any) {
|
||||||
|
setAvatar(event.target.files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(values: any) {
|
async function handleSubmit(values: any) {
|
||||||
const userUpdate: UserUpdateDto = {
|
const userUpdate: UserUpdateDto = {
|
||||||
username: values.username,
|
username: values.username,
|
||||||
@@ -91,11 +97,21 @@ export default function ProfileManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row flex-1 justify-between gap-16">
|
<div className="flex flex-row flex-1 justify-between gap-16">
|
||||||
<div className="flex flex-col basis-1/4 mt-8 items-center">
|
<div className="flex flex-col basis-1/4 mt-8 gap-4">
|
||||||
<Avatar showFallback
|
<div className="flex flex-row justify-center">
|
||||||
src={`/images/avatar?username=${auth.state.user?.username}`}
|
<Avatar showFallback
|
||||||
className="size-40 m-4"></Avatar>
|
src={`/images/avatar?username=${auth.state.user?.username}`}
|
||||||
<AvatarUpload upload="/avatar/upload" remove="/avatar/delete" accept="image/*"/>
|
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>
|
||||||
|
|
||||||
<div className="flex flex-col flex-grow">
|
<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 ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||||
import Section from "Frontend/components/general/Section";
|
import Section from "Frontend/components/general/Section";
|
||||||
import * as Yup from "yup";
|
|
||||||
import {UserEndpoint} from "Frontend/generated/endpoints";
|
import {UserEndpoint} from "Frontend/generated/endpoints";
|
||||||
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
|
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) {
|
function UserManagementLayout({getConfig, formik}: any) {
|
||||||
const [users, setUsers] = useState<UserInfoDto[]>([]);
|
const [users, setUsers] = useState<UserInfoDto[]>([]);
|
||||||
@@ -28,23 +27,11 @@ function UserManagementLayout({getConfig, formik}: any) {
|
|||||||
|
|
||||||
<Section title="Users"/>
|
<Section title="Users"/>
|
||||||
<div className="grid grid-cols-300px gap-4">
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationSchema = Yup.object({
|
export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users");
|
||||||
library: Yup.object({
|
|
||||||
metadata: Yup.object({
|
|
||||||
update: Yup.object({
|
|
||||||
// @ts-ignore
|
|
||||||
schedule: Yup.string().cron()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users", validationSchema);
|
|
||||||
@@ -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 {type ClassValue, clsx} from "clsx"
|
||||||
import {twMerge} from "tailwind-merge"
|
import {twMerge} from "tailwind-merge"
|
||||||
|
import {getCsrfToken} from "Frontend/util/auth";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -35,4 +36,15 @@ export function roleToColor(role: string) {
|
|||||||
default:
|
default:
|
||||||
return "gray";
|
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)
|
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||||
fun deleteUser(username: String) {
|
fun deleteUserByName(username: String) {
|
||||||
userService.deleteUser(username)
|
userService.deleteUser(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.grimsi.gameyfin.users.avatar
|
package de.grimsi.gameyfin.users.avatar
|
||||||
|
|
||||||
|
import de.grimsi.gameyfin.meta.Roles
|
||||||
import de.grimsi.gameyfin.users.UserService
|
import de.grimsi.gameyfin.users.UserService
|
||||||
import jakarta.annotation.security.PermitAll
|
import jakarta.annotation.security.PermitAll
|
||||||
|
import jakarta.annotation.security.RolesAllowed
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
import org.springframework.core.io.InputStreamResource
|
import org.springframework.core.io.InputStreamResource
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
@@ -33,6 +35,12 @@ class AvatarController(
|
|||||||
userService.deleteAvatar(auth.name)
|
userService.deleteAvatar(auth.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||||
|
@PostMapping("/avatar/deleteByName")
|
||||||
|
fun deleteAvatarByName(@RequestParam("name") name: String) {
|
||||||
|
userService.deleteAvatar(name)
|
||||||
|
}
|
||||||
|
|
||||||
@PermitAll
|
@PermitAll
|
||||||
@GetMapping("/images/avatar")
|
@GetMapping("/images/avatar")
|
||||||
fun getAvatar(
|
fun getAvatar(
|
||||||
|
|||||||
Reference in New Issue
Block a user