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
pull/1290/head
TheCatLady 3 years ago committed by GitHub
parent 49782c0b73
commit a9461f760d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -3957,6 +3957,8 @@ paths:
summary: Get all requests
description: |
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:
- request
parameters:
@ -3984,6 +3986,12 @@ paths:
type: string
enum: [added, modified]
default: added
- in: query
name: requestedBy
schema:
type: number
nullable: true
example: 1
responses:
'200':
description: Requests returned
@ -4593,7 +4601,7 @@ paths:
type: number
/media:
get:
summary: Return media
summary: Get media
description: Returns all media (can be filtered and limited) in a JSON object.
tags:
- media

@ -17,6 +17,9 @@ requestRoutes.get('/', async (req, res, next) => {
try {
const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const requestedBy = req.query.requestedBy
? Number(req.query.requestedBy)
: null;
let statusFilter: MediaRequestStatus[];
@ -100,9 +103,20 @@ requestRoutes.get('/', async (req, res, next) => {
{ 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', {
id: req.user?.id,
});
} else if (requestedBy) {
query = query.andWhere('requestedBy.id = :id', {
id: requestedBy,
});
}
const [requests, requestCount] = await query

@ -1,8 +1,10 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Button from '../Common/Button';
import Header from '../Common/Header';
@ -31,6 +33,9 @@ type Sort = 'added' | 'modified';
const RequestList: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const { user } = useUser({
id: Number(router.query.userId),
});
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
const [currentSort, setCurrentSort] = useState<Sort>('added');
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
@ -41,7 +46,9 @@ const RequestList: React.FC = () => {
const { data, error, revalidate } = useSWR<RequestResultsResponse>(
`/api/v1/request?take=${currentPageSize}&skip=${
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
@ -87,9 +94,26 @@ const RequestList: React.FC = () => {
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">
<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-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">
@ -111,7 +135,12 @@ const RequestList: React.FC = () => {
name="filter"
onChange={(e) => {
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}
className="rounded-r-only"
@ -152,7 +181,12 @@ const RequestList: React.FC = () => {
name="sort"
onChange={(e) => {
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}
className="rounded-r-only"
@ -226,7 +260,12 @@ const RequestList: React.FC = () => {
onChange={(e) => {
setCurrentPageSize(Number(e.target.value));
router
.push(router.pathname)
.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
})
.then(() => window.scrollTo(0, 0));
}}
value={currentPageSize}
@ -247,9 +286,18 @@ const RequestList: React.FC = () => {
disabled={!hasPrevPage}
onClick={() =>
router
.push(`${router.pathname}?page=${page - 1}`, undefined, {
shallow: true,
})
.push(
{
pathname: `${router.pathname}?page=${page - 1}`,
query: router.query.userId
? { userId: router.query.userId }
: {},
},
undefined,
{
shallow: true,
}
)
.then(() => window.scrollTo(0, 0))
}
>
@ -259,9 +307,18 @@ const RequestList: React.FC = () => {
disabled={!hasNextPage}
onClick={() =>
router
.push(`${router.pathname}?page=${page + 1}`, undefined, {
shallow: true,
})
.push(
{
pathname: `${router.pathname}?page=${page + 1}`,
query: router.query.userId
? { userId: router.query.userId }
: {},
},
undefined,
{
shallow: true,
}
)
.then(() => window.scrollTo(0, 0))
}
>

@ -101,7 +101,7 @@ const UserList: React.FC = () => {
});
const [showBulkEditModal, setShowBulkEditModal] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
const { user: currentUser } = useUser();
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
useEffect(() => {
const filterString = window.localStorage.getItem('ul-filter-settings');
@ -538,7 +538,7 @@ const UserList: React.FC = () => {
</Link>
<div className="ml-4">
<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}
</a>
</Link>
@ -549,7 +549,19 @@ const UserList: React.FC = () => {
</div>
</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>
{user.userType === UserType.PLEX ? (

@ -1,3 +1,4 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -225,29 +226,51 @@ const UserProfile: React.FC = () => {
</dl>
</div>
)}
<div className="relative z-40 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 cursor-default sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.recentrequests)}</span>
{(user.id === currentUser?.id ||
currentHasPermission(
[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>
<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 className="relative z-40">
<Slider
sliderKey="requests"
isLoading={!requests && !requestError}
isEmpty={!!requests && !requestError && requests.results.length === 0}
items={(requests?.results ?? []).map((request) => (
<RequestCard
key={`request-slider-item-${request.id}`}
request={request}
onTitleData={updateAvailableTitles}
/>
))}
placeholder={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.norequests)}
/>
</div>
<Slider
sliderKey="requests"
isLoading={!requests && !requestError}
isEmpty={
!!requests && !requestError && requests.results.length === 0
}
items={(requests?.results ?? []).map((request) => (
<RequestCard
key={`request-slider-item-${request.id}`}
request={request}
onTitleData={updateAvailableTitles}
/>
))}
placeholder={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.norequests)}
/>
</>
)}
</>
);
};

@ -1,16 +1,19 @@
import { Permission, useUser } from './useUser';
import { useRouter } from 'next/router';
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 { user, hasPermission } = useUser();
useEffect(() => {
if (user && !hasPermission(permission)) {
if (user && !hasPermission(permission, options)) {
router.push('/');
}
}, [user, permission, router, hasPermission]);
}, [user, permission, router, hasPermission, options]);
};
export default useRouteGuard;

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

@ -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;
Loading…
Cancel
Save