Add permission checks

Add UI for config
This commit is contained in:
grimsi
2025-09-03 10:26:17 +02:00
parent c50c3e43bb
commit 6e86584fd8
12 changed files with 1072 additions and 1280 deletions
+798 -1124
View File
File diff suppressed because it is too large Load Diff
+109 -109
View File
@@ -9,22 +9,22 @@
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@react-stately/data": "^3.12.2", "@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0", "@react-types/shared": "^3.28.0",
"@vaadin/bundles": "24.8.3", "@vaadin/bundles": "24.8.6",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.2", "@vaadin/hilla-file-router": "24.8.7",
"@vaadin/hilla-frontend": "24.8.2", "@vaadin/hilla-frontend": "24.8.7",
"@vaadin/hilla-lit-form": "24.8.2", "@vaadin/hilla-lit-form": "24.8.7",
"@vaadin/hilla-react-auth": "24.8.2", "@vaadin/hilla-react-auth": "24.8.7",
"@vaadin/hilla-react-crud": "24.8.2", "@vaadin/hilla-react-crud": "24.8.7",
"@vaadin/hilla-react-form": "24.8.2", "@vaadin/hilla-react-form": "24.8.7",
"@vaadin/hilla-react-i18n": "24.8.2", "@vaadin/hilla-react-i18n": "24.8.7",
"@vaadin/hilla-react-signals": "24.8.2", "@vaadin/hilla-react-signals": "24.8.7",
"@vaadin/polymer-legacy-adapter": "24.8.3", "@vaadin/polymer-legacy-adapter": "24.8.6",
"@vaadin/react-components": "24.8.3", "@vaadin/react-components": "24.8.6",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.8.3", "@vaadin/vaadin-lumo-styles": "24.8.6",
"@vaadin/vaadin-material-styles": "24.8.3", "@vaadin/vaadin-material-styles": "24.8.6",
"@vaadin/vaadin-themable-mixin": "24.8.3", "@vaadin/vaadin-themable-mixin": "24.8.6",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"construct-style-sheets-polyfill": "3.1.0", "construct-style-sheets-polyfill": "3.1.0",
@@ -61,17 +61,17 @@
"@types/node": "^22.4.0", "@types/node": "^22.4.0",
"@types/react": "18.3.23", "@types/react": "18.3.23",
"@types/react-dom": "18.3.7", "@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.8.2", "@vaadin/hilla-generator-cli": "24.8.7",
"@vaadin/hilla-generator-core": "24.8.2", "@vaadin/hilla-generator-core": "24.8.7",
"@vaadin/hilla-generator-plugin-backbone": "24.8.2", "@vaadin/hilla-generator-plugin-backbone": "24.8.7",
"@vaadin/hilla-generator-plugin-barrel": "24.8.2", "@vaadin/hilla-generator-plugin-barrel": "24.8.7",
"@vaadin/hilla-generator-plugin-client": "24.8.2", "@vaadin/hilla-generator-plugin-client": "24.8.7",
"@vaadin/hilla-generator-plugin-model": "24.8.2", "@vaadin/hilla-generator-plugin-model": "24.8.7",
"@vaadin/hilla-generator-plugin-push": "24.8.2", "@vaadin/hilla-generator-plugin-push": "24.8.7",
"@vaadin/hilla-generator-plugin-signals": "24.8.2", "@vaadin/hilla-generator-plugin-signals": "24.8.7",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.2", "@vaadin/hilla-generator-plugin-subtypes": "24.8.7",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.2", "@vaadin/hilla-generator-plugin-transfertypes": "24.8.7",
"@vaadin/hilla-generator-utils": "24.8.2", "@vaadin/hilla-generator-utils": "24.8.7",
"@vitejs/plugin-react": "4.5.0", "@vitejs/plugin-react": "4.5.0",
"@vitejs/plugin-react-swc": "^3.7.0", "@vitejs/plugin-react-swc": "^3.7.0",
"async": "3.2.6", "async": "3.2.6",
@@ -142,85 +142,85 @@
"valtio": "$valtio", "valtio": "$valtio",
"valtio-reactive": "$valtio-reactive", "valtio-reactive": "$valtio-reactive",
"fzf": "$fzf", "fzf": "$fzf",
"@vaadin/a11y-base": "24.8.3", "@vaadin/a11y-base": "24.8.6",
"@vaadin/accordion": "24.8.3", "@vaadin/accordion": "24.8.6",
"@vaadin/app-layout": "24.8.3", "@vaadin/app-layout": "24.8.6",
"@vaadin/avatar": "24.8.3", "@vaadin/avatar": "24.8.6",
"@vaadin/avatar-group": "24.8.3", "@vaadin/avatar-group": "24.8.6",
"@vaadin/button": "24.8.3", "@vaadin/button": "24.8.6",
"@vaadin/card": "24.8.3", "@vaadin/card": "24.8.6",
"@vaadin/checkbox": "24.8.3", "@vaadin/checkbox": "24.8.6",
"@vaadin/checkbox-group": "24.8.3", "@vaadin/checkbox-group": "24.8.6",
"@vaadin/combo-box": "24.8.3", "@vaadin/combo-box": "24.8.6",
"@vaadin/component-base": "24.8.3", "@vaadin/component-base": "24.8.6",
"@vaadin/confirm-dialog": "24.8.3", "@vaadin/confirm-dialog": "24.8.6",
"@vaadin/context-menu": "24.8.3", "@vaadin/context-menu": "24.8.6",
"@vaadin/custom-field": "24.8.3", "@vaadin/custom-field": "24.8.6",
"@vaadin/date-picker": "24.8.3", "@vaadin/date-picker": "24.8.6",
"@vaadin/date-time-picker": "24.8.3", "@vaadin/date-time-picker": "24.8.6",
"@vaadin/details": "24.8.3", "@vaadin/details": "24.8.6",
"@vaadin/dialog": "24.8.3", "@vaadin/dialog": "24.8.6",
"@vaadin/email-field": "24.8.3", "@vaadin/email-field": "24.8.6",
"@vaadin/field-base": "24.8.3", "@vaadin/field-base": "24.8.6",
"@vaadin/field-highlighter": "24.8.3", "@vaadin/field-highlighter": "24.8.6",
"@vaadin/form-layout": "24.8.3", "@vaadin/form-layout": "24.8.6",
"@vaadin/grid": "24.8.3", "@vaadin/grid": "24.8.6",
"@vaadin/horizontal-layout": "24.8.3", "@vaadin/horizontal-layout": "24.8.6",
"@vaadin/icon": "24.8.3", "@vaadin/icon": "24.8.6",
"@vaadin/icons": "24.8.3", "@vaadin/icons": "24.8.6",
"@vaadin/input-container": "24.8.3", "@vaadin/input-container": "24.8.6",
"@vaadin/integer-field": "24.8.3", "@vaadin/integer-field": "24.8.6",
"@vaadin/item": "24.8.3", "@vaadin/item": "24.8.6",
"@vaadin/list-box": "24.8.3", "@vaadin/list-box": "24.8.6",
"@vaadin/lit-renderer": "24.8.3", "@vaadin/lit-renderer": "24.8.6",
"@vaadin/login": "24.8.3", "@vaadin/login": "24.8.6",
"@vaadin/markdown": "24.8.3", "@vaadin/markdown": "24.8.6",
"@vaadin/master-detail-layout": "24.8.3", "@vaadin/master-detail-layout": "24.8.6",
"@vaadin/menu-bar": "24.8.3", "@vaadin/menu-bar": "24.8.6",
"@vaadin/message-input": "24.8.3", "@vaadin/message-input": "24.8.6",
"@vaadin/message-list": "24.8.3", "@vaadin/message-list": "24.8.6",
"@vaadin/multi-select-combo-box": "24.8.3", "@vaadin/multi-select-combo-box": "24.8.6",
"@vaadin/notification": "24.8.3", "@vaadin/notification": "24.8.6",
"@vaadin/number-field": "24.8.3", "@vaadin/number-field": "24.8.6",
"@vaadin/overlay": "24.8.3", "@vaadin/overlay": "24.8.6",
"@vaadin/password-field": "24.8.3", "@vaadin/password-field": "24.8.6",
"@vaadin/popover": "24.8.3", "@vaadin/popover": "24.8.6",
"@vaadin/progress-bar": "24.8.3", "@vaadin/progress-bar": "24.8.6",
"@vaadin/radio-group": "24.8.3", "@vaadin/radio-group": "24.8.6",
"@vaadin/scroller": "24.8.3", "@vaadin/scroller": "24.8.6",
"@vaadin/select": "24.8.3", "@vaadin/select": "24.8.6",
"@vaadin/side-nav": "24.8.3", "@vaadin/side-nav": "24.8.6",
"@vaadin/split-layout": "24.8.3", "@vaadin/split-layout": "24.8.6",
"@vaadin/tabs": "24.8.3", "@vaadin/tabs": "24.8.6",
"@vaadin/tabsheet": "24.8.3", "@vaadin/tabsheet": "24.8.6",
"@vaadin/text-area": "24.8.3", "@vaadin/text-area": "24.8.6",
"@vaadin/text-field": "24.8.3", "@vaadin/text-field": "24.8.6",
"@vaadin/time-picker": "24.8.3", "@vaadin/time-picker": "24.8.6",
"@vaadin/tooltip": "24.8.3", "@vaadin/tooltip": "24.8.6",
"@vaadin/upload": "24.8.3", "@vaadin/upload": "24.8.6",
"@vaadin/router": "2.0.0", "@vaadin/router": "2.0.0",
"@vaadin/vertical-layout": "24.8.3", "@vaadin/vertical-layout": "24.8.6",
"@vaadin/virtual-list": "24.8.3" "@vaadin/virtual-list": "24.8.6"
}, },
"vaadin": { "vaadin": {
"dependencies": { "dependencies": {
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.8.3", "@vaadin/bundles": "24.8.6",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.2", "@vaadin/hilla-file-router": "24.8.7",
"@vaadin/hilla-frontend": "24.8.2", "@vaadin/hilla-frontend": "24.8.7",
"@vaadin/hilla-lit-form": "24.8.2", "@vaadin/hilla-lit-form": "24.8.7",
"@vaadin/hilla-react-auth": "24.8.2", "@vaadin/hilla-react-auth": "24.8.7",
"@vaadin/hilla-react-crud": "24.8.2", "@vaadin/hilla-react-crud": "24.8.7",
"@vaadin/hilla-react-form": "24.8.2", "@vaadin/hilla-react-form": "24.8.7",
"@vaadin/hilla-react-i18n": "24.8.2", "@vaadin/hilla-react-i18n": "24.8.7",
"@vaadin/hilla-react-signals": "24.8.2", "@vaadin/hilla-react-signals": "24.8.7",
"@vaadin/polymer-legacy-adapter": "24.8.3", "@vaadin/polymer-legacy-adapter": "24.8.6",
"@vaadin/react-components": "24.8.3", "@vaadin/react-components": "24.8.6",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.8.3", "@vaadin/vaadin-lumo-styles": "24.8.6",
"@vaadin/vaadin-material-styles": "24.8.3", "@vaadin/vaadin-material-styles": "24.8.6",
"@vaadin/vaadin-themable-mixin": "24.8.3", "@vaadin/vaadin-themable-mixin": "24.8.6",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0", "construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3", "date-fns": "2.29.3",
@@ -236,17 +236,17 @@
"@rollup/pluginutils": "5.1.4", "@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.23", "@types/react": "18.3.23",
"@types/react-dom": "18.3.7", "@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.8.2", "@vaadin/hilla-generator-cli": "24.8.7",
"@vaadin/hilla-generator-core": "24.8.2", "@vaadin/hilla-generator-core": "24.8.7",
"@vaadin/hilla-generator-plugin-backbone": "24.8.2", "@vaadin/hilla-generator-plugin-backbone": "24.8.7",
"@vaadin/hilla-generator-plugin-barrel": "24.8.2", "@vaadin/hilla-generator-plugin-barrel": "24.8.7",
"@vaadin/hilla-generator-plugin-client": "24.8.2", "@vaadin/hilla-generator-plugin-client": "24.8.7",
"@vaadin/hilla-generator-plugin-model": "24.8.2", "@vaadin/hilla-generator-plugin-model": "24.8.7",
"@vaadin/hilla-generator-plugin-push": "24.8.2", "@vaadin/hilla-generator-plugin-push": "24.8.7",
"@vaadin/hilla-generator-plugin-signals": "24.8.2", "@vaadin/hilla-generator-plugin-signals": "24.8.7",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.2", "@vaadin/hilla-generator-plugin-subtypes": "24.8.7",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.2", "@vaadin/hilla-generator-plugin-transfertypes": "24.8.7",
"@vaadin/hilla-generator-utils": "24.8.2", "@vaadin/hilla-generator-utils": "24.8.7",
"@vitejs/plugin-react": "4.5.0", "@vitejs/plugin-react": "4.5.0",
"async": "3.2.6", "async": "3.2.6",
"glob": "11.0.2", "glob": "11.0.2",
@@ -263,6 +263,6 @@
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
}, },
"disableUsageStatistics": true, "disableUsageStatistics": true,
"hash": "962eccc3fa0735d5234901be4f9e384096113c45bec22564a53688096d62aef4" "hash": "e499c8893c397649c698f302e100ee1e48833c88e57bd0829fbf86dc5a14cfd8"
} }
} }
@@ -0,0 +1,49 @@
import React from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {Button} from "@heroui/react";
import {useNavigate} from "react-router";
function GameRequestManagementLayout({getConfig, formik}: any) {
const navigate = useNavigate();
return (
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex flex-col flex-1">
<Section title="Game requests configuration"/>
<ConfigFormField configElement={getConfig("requests.games.enabled")}/>
<Section title="Permissions"/>
<div className="flex flex-row items-center gap-4">
<ConfigFormField
configElement={getConfig("requests.games.allow-guests-to-request-games")}
isDisabled={!formik.values.library["allow-public-access"]}/>
<ConfigFormField configElement={getConfig("requests.games.max-open-requests-per-user")}/>
</div>
<Button onPress={() => navigate("/requests")}>
Manage game requests
</Button>
</div>
</div>
</div>
);
}
const validationSchema = Yup.object({
requests: Yup.object({
games: Yup.object({
enabled: Yup.boolean().required("Required"),
"allow-guests-to-request-games": Yup.boolean().required("Required"),
"max-open-requests-per-user": Yup.number()
.min(0, "Must be at least 0")
.max(Number.MAX_SAFE_INTEGER, `Must be lower than ${Number.MAX_SAFE_INTEGER}`)
.required("Required"),
}).required("Required"),
}).required("Required"),
});
export const GameRequestManagement = withConfigPage(GameRequestManagementLayout, "Game Requests", validationSchema);
@@ -49,13 +49,19 @@ export default function RequestGameModal({
title: game.title, title: game.title,
release: game.release release: game.release
} }
await GameRequestEndpoint.create(request);
addToast({ try {
title: "Request submitted", await GameRequestEndpoint.create(request);
description: `Your request for "${game.title}" has been submitted.`,
color: "success" addToast({
}) title: "Request submitted",
description: `Your request for "${game.title}" has been submitted.`,
color: "success"
});
} catch (e) {
setIsSearching(false);
setIsRequesting(null);
}
} }
async function search() { async function search() {
+6
View File
@@ -25,6 +25,7 @@ import LibraryView from "Frontend/views/LibraryView";
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js"; import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
import ErrorView from "Frontend/views/ErrorView"; import ErrorView from "Frontend/views/ErrorView";
import GameRequestView from "Frontend/views/GameRequestView"; import GameRequestView from "Frontend/views/GameRequestView";
import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement";
export const {router, routes} = new RouterConfigurationBuilder() export const {router, routes} = new RouterConfigurationBuilder()
.withReactRoutes([ .withReactRoutes([
@@ -93,6 +94,11 @@ export const {router, routes} = new RouterConfigurationBuilder()
element: <LibraryManagementView/>, element: <LibraryManagementView/>,
handle: {title: 'Administration - Library'} handle: {title: 'Administration - Library'}
}, },
{
path: 'requests',
element: <GameRequestManagement/>,
handle: {title: 'Administration - Game Requests'}
},
{ {
path: 'users', path: 'users',
element: <UserManagement/>, element: <UserManagement/>,
@@ -1,4 +1,4 @@
import {Envelope, GameController, LockKey, Log, Plug, Users, Wrench} from "@phosphor-icons/react"; import {Disc, Envelope, GameController, LockKey, Log, Plug, Users, Wrench} from "@phosphor-icons/react";
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu"; import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
@@ -7,6 +7,11 @@ const menuItems: MenuItem[] = [
url: "libraries", url: "libraries",
icon: <GameController/> icon: <GameController/>
}, },
{
title: "Game Requests",
url: "requests",
icon: <Disc/>
},
{ {
title: "Users", title: "Users",
url: "users", url: "users",
@@ -16,22 +16,29 @@ import {
useDisclosure useDisclosure
} from "@heroui/react"; } from "@heroui/react";
import RequestGameModal from "Frontend/components/general/modals/RequestGameModal"; import RequestGameModal from "Frontend/components/general/modals/RequestGameModal";
import {ArrowUp, Check, PlusCircle, X} from "@phosphor-icons/react"; import {ArrowUp, Check, Info, PlusCircle, Trash, X} from "@phosphor-icons/react";
import React, {useMemo, useState} from "react"; import React, {useEffect, useMemo, useState} from "react";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import {GameRequestEndpoint} from "Frontend/generated/endpoints"; import {ConfigEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
import {gameRequestState} from "Frontend/state/GameRequestState"; import {gameRequestState} from "Frontend/state/GameRequestState";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import GameRequestDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestDto"; import GameRequestDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestDto";
import GameRequestStatus from "Frontend/generated/org/gameyfin/app/requests/status/GameRequestStatus"; import GameRequestStatus from "Frontend/generated/org/gameyfin/app/requests/status/GameRequestStatus";
import {isAdmin} from "Frontend/util/utils"; import {isAdmin} from "Frontend/util/utils";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
export default function GameRequestView() { export default function GameRequestView() {
const rowsPerPage = 25; const rowsPerPage = 25;
const auth = useAuth(); const auth = useAuth();
const requestGameModal = useDisclosure(); const requestGameModal = useDisclosure();
const gameRequests = useSnapshot(gameRequestState).gameRequests const gameRequests = useSnapshot(gameRequestState).gameRequests;
const [areGameRequestsEnabled, setAreGameRequestsEnabled] = useState(false);
useEffect(() => {
ConfigEndpoint.areGameRequestsEnabled().then(setAreGameRequestsEnabled);
}, []);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [filters, setFilters] = useState<"all" | GameRequestStatus[]>([GameRequestStatus.PENDING, GameRequestStatus.APPROVED, GameRequestStatus.REJECTED]); const [filters, setFilters] = useState<"all" | GameRequestStatus[]>([GameRequestStatus.PENDING, GameRequestStatus.APPROVED, GameRequestStatus.REJECTED]);
@@ -94,7 +101,6 @@ export default function GameRequestView() {
return sortedItems.slice(start, end); return sortedItems.slice(start, end);
}, [page, sortedItems]); }, [page, sortedItems]);
function getFilteredRequests() { function getFilteredRequests() {
let filteredRequests = (gameRequests as GameRequestDto[]).filter((gameRequest) => { let filteredRequests = (gameRequests as GameRequestDto[]).filter((gameRequest) => {
return gameRequest.title.toLowerCase().includes(searchTerm.toLowerCase()) || return gameRequest.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -124,6 +130,10 @@ export default function GameRequestView() {
await GameRequestEndpoint.changeStatus(gameRequest.id, newStatus); await GameRequestEndpoint.changeStatus(gameRequest.id, newStatus);
} }
async function deleteRequest(gameRequestId: number) {
await GameRequestEndpoint.delete(gameRequestId);
}
function hasUserVotedForRequest(gameRequest: GameRequestDto): boolean { function hasUserVotedForRequest(gameRequest: GameRequestDto): boolean {
if (!auth.state.user) return false; if (!auth.state.user) return false;
return gameRequest.voters.map(v => v.id).includes(auth.state.user.id); return gameRequest.voters.map(v => v.id).includes(auth.state.user.id);
@@ -149,10 +159,16 @@ export default function GameRequestView() {
<div className="flex flex-row justify-between mb-8"> <div className="flex flex-row justify-between mb-8">
<h1 className="text-2xl font-bold">Game Requests</h1> <h1 className="text-2xl font-bold">Game Requests</h1>
<div className="flex flex-row items-center gap-4"> <div className="flex flex-row items-center gap-4">
{!areGameRequestsEnabled &&
<SmallInfoField icon={Info}
message="Request submission is disabled"
className="text-foreground/70"/>
}
<Button className="w-fit" <Button className="w-fit"
color="primary" color="primary"
startContent={<PlusCircle weight="fill"/>} startContent={<PlusCircle weight="fill"/>}
onPress={requestGameModal.onOpen}> onPress={requestGameModal.onOpen}
isDisabled={!areGameRequestsEnabled}>
Request a Game Request a Game
</Button> </Button>
</div> </div>
@@ -176,8 +192,8 @@ export default function GameRequestView() {
> >
<SelectItem key={GameRequestStatus.PENDING}>Pending</SelectItem> <SelectItem key={GameRequestStatus.PENDING}>Pending</SelectItem>
<SelectItem key={GameRequestStatus.APPROVED}>Approved</SelectItem> <SelectItem key={GameRequestStatus.APPROVED}>Approved</SelectItem>
<SelectItem key={GameRequestStatus.FULFILLED}>Fulfilled</SelectItem>
<SelectItem key={GameRequestStatus.REJECTED}>Rejected</SelectItem> <SelectItem key={GameRequestStatus.REJECTED}>Rejected</SelectItem>
<SelectItem key={GameRequestStatus.FULFILLED}>Fulfilled</SelectItem>
</Select> </Select>
</div> </div>
@@ -263,6 +279,15 @@ export default function GameRequestView() {
</Button> </Button>
</Tooltip> </Tooltip>
</div>} </div>}
{(isAdmin(auth) || (auth.state.user && item.requester && auth.state.user.id === item.requester.id)) &&
<Tooltip content="Delete this request">
<Button size="sm" isIconOnly
color="danger"
onPress={async () => await deleteRequest(item.id)}>
<Trash/>
</Button>
</Tooltip>
}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -270,7 +295,6 @@ export default function GameRequestView() {
</TableBody> </TableBody>
</Table> </Table>
<RequestGameModal isOpen={requestGameModal.isOpen} <RequestGameModal isOpen={requestGameModal.isOpen}
onOpenChange={requestGameModal.onOpenChange}/> onOpenChange={requestGameModal.onOpenChange}/>
</>) </>)
+5 -1
View File
@@ -108,7 +108,11 @@ export default function MainLayout() {
} }
{isAdmin(auth) && {isAdmin(auth) &&
<NavbarItem> <NavbarItem>
<ScanProgressPopover/> <Tooltip content="View library scan results" placement="bottom">
<div>
<ScanProgressPopover/>
</div>
</Tooltip>
</NavbarItem> </NavbarItem>
} }
{auth.state.user && {auth.state.user &&
@@ -109,7 +109,7 @@ sealed class ConfigProperties<T : Serializable>(
data object Enabled : ConfigProperties<Boolean>( data object Enabled : ConfigProperties<Boolean>(
Boolean::class, Boolean::class,
"requests.games.enabled", "requests.games.enabled",
"Enable game requests", "Enable submission of game requests",
true true
) )
@@ -123,7 +123,7 @@ sealed class ConfigProperties<T : Serializable>(
data object MaxOpenRequestsPerUser : ConfigProperties<Int>( data object MaxOpenRequestsPerUser : ConfigProperties<Int>(
Int::class, Int::class,
"requests.games.max-open-requests-per-user", "requests.games.max-open-requests-per-user",
"Maximum number of open (not yet fulfilled or rejected) requests per user. Set to 0 for unlimited.", "Maximum number of pending requests per user. Set to 0 for unlimited.",
10 10
) )
} }
@@ -2,5 +2,14 @@ package org.gameyfin.app.games.repositories
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.Instant
interface GameRepository : JpaRepository<Game, Long> interface GameRepository : JpaRepository<Game, Long> {
@Query("SELECT g FROM Game g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release)")
fun findByTitleAndReleaseYear(
@Param("title") title: String,
@Param("release") release: Instant?
): List<Game>
}
@@ -8,22 +8,22 @@ import org.springframework.data.repository.query.Param
import java.time.Instant import java.time.Instant
interface GameRequestRepository : JpaRepository<GameRequest, Long> { interface GameRequestRepository : JpaRepository<GameRequest, Long> {
@Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release) AND g.status NOT IN (:excludedStatuses)") @Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release)")
fun findOpenRequestsByTitleAndReleaseYear( fun findByTitleAndReleaseYear(
@Param("title") title: String, @Param("title") title: String,
@Param("release") release: Instant?, @Param("release") release: Instant?
@Param("excludedStatuses") excludedStatuses: List<GameRequestStatus> = listOf(
GameRequestStatus.FULFILLED,
GameRequestStatus.REJECTED
)
): List<GameRequest> ): List<GameRequest>
@Query("SELECT g FROM GameRequest g WHERE g.requester.id = :requesterId AND g.status NOT IN (:excludedStatuses)") @Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release) AND g.status NOT IN (:excludedStatuses)")
fun findOpenRequestsByRequesterId( fun findRequestsByTitleAndReleaseYearAndStatusNotIn(
@Param("title") title: String,
@Param("release") release: Instant?,
@Param("excludedStatuses") excludedStatuses: List<GameRequestStatus>
): List<GameRequest>
@Query("SELECT g FROM GameRequest g WHERE g.requester.id = :requesterId AND g.status IN (:statuses)")
fun findRequestsByRequesterIdAndStatusIn(
@Param("requesterId") requesterId: Long?, @Param("requesterId") requesterId: Long?,
@Param("excludedStatuses") excludedStatuses: List<GameRequestStatus> = listOf( @Param("statuses") statuses: List<GameRequestStatus>
GameRequestStatus.FULFILLED,
GameRequestStatus.REJECTED
)
): List<GameRequest> ): List<GameRequest>
} }
@@ -1,11 +1,13 @@
package org.gameyfin.app.requests package org.gameyfin.app.requests
import com.vaadin.hilla.exception.EndpointException
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.config.ConfigProperties import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService import org.gameyfin.app.config.ConfigService
import org.gameyfin.app.core.events.GameCreatedEvent import org.gameyfin.app.core.events.GameCreatedEvent
import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.core.security.isAdmin import org.gameyfin.app.core.security.isAdmin
import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.requests.dto.GameRequestCreationDto import org.gameyfin.app.requests.dto.GameRequestCreationDto
import org.gameyfin.app.requests.dto.GameRequestDto import org.gameyfin.app.requests.dto.GameRequestDto
import org.gameyfin.app.requests.dto.GameRequestEvent import org.gameyfin.app.requests.dto.GameRequestEvent
@@ -26,9 +28,10 @@ import kotlin.time.toJavaDuration
@Service @Service
class GameRequestService( class GameRequestService(
private val gameRequestRepository: GameRequestRepository, private val config: ConfigService,
private val userService: UserService, private val userService: UserService,
private val config: ConfigService private val gameRequestRepository: GameRequestRepository,
private val gameRepository: GameRepository
) { ) {
companion object { companion object {
@@ -63,17 +66,24 @@ class GameRequestService(
// Check if requests are enabled // Check if requests are enabled
if (config.get(ConfigProperties.Requests.Games.Enabled) != true) { if (config.get(ConfigProperties.Requests.Games.Enabled) != true) {
throw IllegalStateException("Game requests are disabled") throw EndpointException("Game requests are disabled")
}
// Check if game is already available
val existingGames = gameRepository.findByTitleAndReleaseYear(gameRequest.title, gameRequest.release)
if (existingGames.isNotEmpty()) {
throw EndpointException(
"This game is already available (ID: ${existingGames[0].id})"
)
} }
// Check if a request with the same title and release year already exists // Check if a request with the same title and release year already exists
val existingRequests = gameRequestRepository.findOpenRequestsByTitleAndReleaseYear( val existingRequests = gameRequestRepository.findByTitleAndReleaseYear(
gameRequest.title, gameRequest.title,
gameRequest.release, gameRequest.release
emptyList()
) )
if (existingRequests.isNotEmpty()) { if (existingRequests.isNotEmpty()) {
throw IllegalStateException("A request for this game already exists (ID: ${existingRequests[0].id})") throw EndpointException("A request for this game already exists (ID: ${existingRequests[0].id})")
} }
val auth = getCurrentAuth() val auth = getCurrentAuth()
@@ -81,16 +91,19 @@ class GameRequestService(
// Check if guests are allowed to create requests // Check if guests are allowed to create requests
if (config.get(ConfigProperties.Requests.Games.AllowGuestsToRequestGames) != true && currentUser == null) { if (config.get(ConfigProperties.Requests.Games.AllowGuestsToRequestGames) != true && currentUser == null) {
throw IllegalStateException("Only registered users can create game requests") throw EndpointException("Only registered users can create game requests")
} }
// Check if user has too many open requests (0 means no limit per user) // Check if user has too many open requests (0 means no limit per user)
// Note: All guests are treated as a single user with null ID and thus share their request limit // Note: All guests are treated as a single user with null ID and thus share their request limit
// Note: Admins are exempt from this limit // Note: Admins are exempt from this limit
val openRequestsForUser = gameRequestRepository.findOpenRequestsByRequesterId(currentUser?.id) val pendingRequestsForUser = gameRequestRepository.findRequestsByRequesterIdAndStatusIn(
currentUser?.id,
listOf(GameRequestStatus.PENDING)
)
val maxRequestsPerUser = config.get(ConfigProperties.Requests.Games.MaxOpenRequestsPerUser) ?: 0 val maxRequestsPerUser = config.get(ConfigProperties.Requests.Games.MaxOpenRequestsPerUser) ?: 0
if (maxRequestsPerUser == 0 || (auth?.isAdmin() != true && openRequestsForUser.size >= maxRequestsPerUser)) { if (maxRequestsPerUser == 0 || (auth?.isAdmin() != true && pendingRequestsForUser.size >= maxRequestsPerUser)) {
throw IllegalStateException("You have reached the maximum number of open requests (${maxRequestsPerUser})") throw EndpointException("You have reached the maximum number of pending requests (${maxRequestsPerUser})")
} }
val newGameRequest = GameRequest( val newGameRequest = GameRequest(
@@ -115,8 +128,9 @@ class GameRequestService(
val requester = gameRequest.requester val requester = gameRequest.requester
// Check if the current user is the requester or an admin // Check if the current user is the requester or an admin
if (auth?.isAdmin() != true || requester == null || requester.id != currentUser?.id) { // Note: Requests submitted by guests (request is null) can only be deleted by an admin
throw IllegalStateException("Only the requester or an admin can delete a game request") if (auth?.isAdmin() != true && (requester == null || requester.id != currentUser?.id)) {
throw EndpointException("Only the requester or an admin can delete a game request")
} }
gameRequestRepository.delete(gameRequest) gameRequestRepository.delete(gameRequest)
@@ -170,9 +184,10 @@ class GameRequestService(
return return
} }
val matchingRequests = gameRequestRepository.findOpenRequestsByTitleAndReleaseYear( val matchingRequests = gameRequestRepository.findRequestsByTitleAndReleaseYearAndStatusNotIn(
gameTitle, gameTitle,
gameRelease gameRelease,
listOf(GameRequestStatus.FULFILLED)
) )
matchingRequests.forEach { request -> matchingRequests.forEach { request ->