feat(ui): Add user requests page (#936)

* feat(ui): add user requests page

* fix: return error if user attempts to fetch another user's requests without adequate perms

* fix(ui): make user name on request page link back to user profile

* feat(ui): link user request count to their filtered request list view

* fix(frontend): only display user requests on profiles if current user has adequate perms

* fix: use 'all' filter for user-filtered request list

* fix(frontend): pass userId to router.push()

* fix: do not pass userId in query for non-user-filtered requests page

* fix(frontend): also allow REQUEST_VIEW perm through route guard

* fix(frontend): only link request count to user request list if current user has required perms
This commit is contained in:
TheCatLady
2021-03-29 00:16:03 -04:00
committed by GitHub
parent 49782c0b73
commit a9461f760d
8 changed files with 176 additions and 44 deletions

View File

@@ -3957,6 +3957,8 @@ paths:
summary: Get all requests summary: Get all requests
description: | description: |
Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned. Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned.
If the `requestedBy` parameter is specified, only requests from that particular user ID will be returned.
tags: tags:
- request - request
parameters: parameters:
@@ -3984,6 +3986,12 @@ paths:
type: string type: string
enum: [added, modified] enum: [added, modified]
default: added default: added
- in: query
name: requestedBy
schema:
type: number
nullable: true
example: 1
responses: responses:
'200': '200':
description: Requests returned description: Requests returned
@@ -4593,7 +4601,7 @@ paths:
type: number type: number
/media: /media:
get: get:
summary: Return media summary: Get media
description: Returns all media (can be filtered and limited) in a JSON object. description: Returns all media (can be filtered and limited) in a JSON object.
tags: tags:
- media - media

View File

@@ -17,6 +17,9 @@ requestRoutes.get('/', async (req, res, next) => {
try { try {
const pageSize = req.query.take ? Number(req.query.take) : 10; const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0; const skip = req.query.skip ? Number(req.query.skip) : 0;
const requestedBy = req.query.requestedBy
? Number(req.query.requestedBy)
: null;
let statusFilter: MediaRequestStatus[]; let statusFilter: MediaRequestStatus[];
@@ -100,9 +103,20 @@ requestRoutes.get('/', async (req, res, next) => {
{ type: 'or' } { type: 'or' }
) )
) { ) {
if (requestedBy && requestedBy !== req.user?.id) {
return next({
status: 403,
message: "You do not have permission to view this user's requests.",
});
}
query = query.andWhere('requestedBy.id = :id', { query = query.andWhere('requestedBy.id = :id', {
id: req.user?.id, id: req.user?.id,
}); });
} else if (requestedBy) {
query = query.andWhere('requestedBy.id = :id', {
id: requestedBy,
});
} }
const [requests, requestCount] = await query const [requests, requestCount] = await query

View File

@@ -1,8 +1,10 @@
import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces'; import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
import Button from '../Common/Button'; import Button from '../Common/Button';
import Header from '../Common/Header'; import Header from '../Common/Header';
@@ -31,6 +33,9 @@ type Sort = 'added' | 'modified';
const RequestList: React.FC = () => { const RequestList: React.FC = () => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { user } = useUser({
id: Number(router.query.userId),
});
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING); const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
const [currentSort, setCurrentSort] = useState<Sort>('added'); const [currentSort, setCurrentSort] = useState<Sort>('added');
const [currentPageSize, setCurrentPageSize] = useState<number>(10); const [currentPageSize, setCurrentPageSize] = useState<number>(10);
@@ -41,7 +46,9 @@ const RequestList: React.FC = () => {
const { data, error, revalidate } = useSWR<RequestResultsResponse>( const { data, error, revalidate } = useSWR<RequestResultsResponse>(
`/api/v1/request?take=${currentPageSize}&skip=${ `/api/v1/request?take=${currentPageSize}&skip=${
pageIndex * currentPageSize pageIndex * currentPageSize
}&filter=${currentFilter}&sort=${currentSort}` }&filter=${currentFilter}&sort=${currentSort}${
router.query.userId ? `&requestedBy=${router.query.userId}` : ''
}`
); );
// Restore last set filter values on component mount // Restore last set filter values on component mount
@@ -87,9 +94,26 @@ const RequestList: React.FC = () => {
return ( return (
<> <>
<PageTitle title={intl.formatMessage(messages.requests)} /> <PageTitle
title={[
intl.formatMessage(messages.requests),
router.query.userId ? user?.displayName : '',
]}
/>
<div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row"> <div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row">
<Header>{intl.formatMessage(messages.requests)}</Header> <Header
subtext={
router.query.userId ? (
<Link href={`/users/${user?.id}`}>
<a className="hover:underline">{user?.displayName}</a>
</Link>
) : (
''
)
}
>
{intl.formatMessage(messages.requests)}
</Header>
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0"> <div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0"> <div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex items-center px-3 text-sm text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md"> <span className="inline-flex items-center px-3 text-sm text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md">
@@ -111,7 +135,12 @@ const RequestList: React.FC = () => {
name="filter" name="filter"
onChange={(e) => { onChange={(e) => {
setCurrentFilter(e.target.value as Filter); setCurrentFilter(e.target.value as Filter);
router.push(router.pathname); router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}} }}
value={currentFilter} value={currentFilter}
className="rounded-r-only" className="rounded-r-only"
@@ -152,7 +181,12 @@ const RequestList: React.FC = () => {
name="sort" name="sort"
onChange={(e) => { onChange={(e) => {
setCurrentSort(e.target.value as Sort); setCurrentSort(e.target.value as Sort);
router.push(router.pathname); router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}} }}
value={currentSort} value={currentSort}
className="rounded-r-only" className="rounded-r-only"
@@ -226,7 +260,12 @@ const RequestList: React.FC = () => {
onChange={(e) => { onChange={(e) => {
setCurrentPageSize(Number(e.target.value)); setCurrentPageSize(Number(e.target.value));
router router
.push(router.pathname) .push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
})
.then(() => window.scrollTo(0, 0)); .then(() => window.scrollTo(0, 0));
}} }}
value={currentPageSize} value={currentPageSize}
@@ -247,9 +286,18 @@ const RequestList: React.FC = () => {
disabled={!hasPrevPage} disabled={!hasPrevPage}
onClick={() => onClick={() =>
router router
.push(`${router.pathname}?page=${page - 1}`, undefined, { .push(
{
pathname: `${router.pathname}?page=${page - 1}`,
query: router.query.userId
? { userId: router.query.userId }
: {},
},
undefined,
{
shallow: true, shallow: true,
}) }
)
.then(() => window.scrollTo(0, 0)) .then(() => window.scrollTo(0, 0))
} }
> >
@@ -259,9 +307,18 @@ const RequestList: React.FC = () => {
disabled={!hasNextPage} disabled={!hasNextPage}
onClick={() => onClick={() =>
router router
.push(`${router.pathname}?page=${page + 1}`, undefined, { .push(
{
pathname: `${router.pathname}?page=${page + 1}`,
query: router.query.userId
? { userId: router.query.userId }
: {},
},
undefined,
{
shallow: true, shallow: true,
}) }
)
.then(() => window.scrollTo(0, 0)) .then(() => window.scrollTo(0, 0))
} }
> >

View File

@@ -101,7 +101,7 @@ const UserList: React.FC = () => {
}); });
const [showBulkEditModal, setShowBulkEditModal] = useState(false); const [showBulkEditModal, setShowBulkEditModal] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<number[]>([]); const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
const { user: currentUser } = useUser(); const { user: currentUser, hasPermission: currentHasPermission } = useUser();
useEffect(() => { useEffect(() => {
const filterString = window.localStorage.getItem('ul-filter-settings'); const filterString = window.localStorage.getItem('ul-filter-settings');
@@ -538,7 +538,7 @@ const UserList: React.FC = () => {
</Link> </Link>
<div className="ml-4"> <div className="ml-4">
<Link href={`/users/${user.id}`}> <Link href={`/users/${user.id}`}>
<a className="text-sm font-medium leading-5"> <a className="text-sm font-medium leading-5 transition duration-300 hover:underline">
{user.displayName} {user.displayName}
</a> </a>
</Link> </Link>
@@ -549,7 +549,19 @@ const UserList: React.FC = () => {
</div> </div>
</Table.TD> </Table.TD>
<Table.TD> <Table.TD>
<div className="text-sm leading-5">{user.requestCount}</div> {user.id === currentUser?.id ||
currentHasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) ? (
<Link href={`/users/${user.id}/requests`}>
<a className="text-sm leading-5 transition duration-300 hover:underline">
{user.requestCount}
</a>
</Link>
) : (
user.requestCount
)}
</Table.TD> </Table.TD>
<Table.TD> <Table.TD>
{user.userType === UserType.PLEX ? ( {user.userType === UserType.PLEX ? (

View File

@@ -1,3 +1,4 @@
import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@@ -225,18 +226,39 @@ const UserProfile: React.FC = () => {
</dl> </dl>
</div> </div>
)} )}
<div className="relative z-40 mt-6 mb-4 md:flex md:items-center md:justify-between"> {(user.id === currentUser?.id ||
<div className="flex-1 min-w-0"> currentHasPermission(
<div className="inline-flex items-center text-xl leading-7 text-gray-300 cursor-default sm:text-2xl sm:leading-9 sm:truncate"> [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
)) && (
<>
<div className="slider-header">
<Link href={`/users/${user?.id}/requests?filter=all`}>
<a className="slider-title">
<span>{intl.formatMessage(messages.recentrequests)}</span> <span>{intl.formatMessage(messages.recentrequests)}</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div> </div>
</div>
</div>
<div className="relative z-40">
<Slider <Slider
sliderKey="requests" sliderKey="requests"
isLoading={!requests && !requestError} isLoading={!requests && !requestError}
isEmpty={!!requests && !requestError && requests.results.length === 0} isEmpty={
!!requests && !requestError && requests.results.length === 0
}
items={(requests?.results ?? []).map((request) => ( items={(requests?.results ?? []).map((request) => (
<RequestCard <RequestCard
key={`request-slider-item-${request.id}`} key={`request-slider-item-${request.id}`}
@@ -247,7 +269,8 @@ const UserProfile: React.FC = () => {
placeholder={<RequestCard.Placeholder />} placeholder={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.norequests)} emptyMessage={intl.formatMessage(messages.norequests)}
/> />
</div> </>
)}
</> </>
); );
}; };

View File

@@ -1,16 +1,19 @@
import { Permission, useUser } from './useUser';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Permission, PermissionCheckOptions, useUser } from './useUser';
const useRouteGuard = (permission: Permission | Permission[]): void => { const useRouteGuard = (
permission: Permission | Permission[],
options?: PermissionCheckOptions
): void => {
const router = useRouter(); const router = useRouter();
const { user, hasPermission } = useUser(); const { user, hasPermission } = useUser();
useEffect(() => { useEffect(() => {
if (user && !hasPermission(permission)) { if (user && !hasPermission(permission, options)) {
router.push('/'); router.push('/');
} }
}, [user, permission, router, hasPermission]); }, [user, permission, router, hasPermission, options]);
}; };
export default useRouteGuard; export default useRouteGuard;

View File

@@ -1,13 +1,14 @@
import useSwr from 'swr'; import useSwr from 'swr';
import { MutatorCallback } from 'swr/dist/types';
import { UserType } from '../../server/constants/user';
import { import {
hasPermission, hasPermission,
Permission, Permission,
PermissionCheckOptions, PermissionCheckOptions,
} from '../../server/lib/permissions'; } from '../../server/lib/permissions';
import { UserType } from '../../server/constants/user';
import { MutatorCallback } from 'swr/dist/types';
export { Permission, UserType }; export { Permission, UserType };
export type { PermissionCheckOptions };
export interface User { export interface User {
id: number; id: number;

View File

@@ -0,0 +1,14 @@
import { NextPage } from 'next';
import React from 'react';
import RequestList from '../../../components/RequestList';
import useRouteGuard from '../../../hooks/useRouteGuard';
import { Permission } from '../../../hooks/useUser';
const UserRequestsPage: NextPage = () => {
useRouteGuard([Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], {
type: 'or',
});
return <RequestList />;
};
export default UserRequestsPage;