mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 10:49:30 -05:00
Compare commits
7 Commits
preview-av
...
preview-so
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a7529dc07 | ||
|
|
cbee8fd843 | ||
|
|
b9435427dc | ||
|
|
8ceec0f9c4 | ||
|
|
5a1040bb61 | ||
|
|
a97a3f3512 | ||
|
|
1dbacec4f9 |
@@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0"
|
||||
name: jellyseerr-chart
|
||||
description: Jellyseerr helm chart for Kubernetes
|
||||
type: application
|
||||
version: 2.6.0
|
||||
appVersion: "2.7.0"
|
||||
version: 2.5.0
|
||||
appVersion: "2.6.0"
|
||||
maintainers:
|
||||
- name: Jellyseerr
|
||||
url: https://github.com/Fallenbagel/jellyseerr
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# jellyseerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Jellyseerr helm chart for Kubernetes
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('User List', () => {
|
||||
cy.get('#email').type(testUser.emailAddress);
|
||||
cy.get('#password').type(testUser.password);
|
||||
|
||||
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
||||
cy.intercept('/api/v1/user*').as('user');
|
||||
|
||||
cy.get('[data-testid=modal-ok-button]').click();
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('User List', () => {
|
||||
|
||||
cy.get('[data-testid=modal-title]').should('contain', `Delete User`);
|
||||
|
||||
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
||||
cy.intercept('/api/v1/user*').as('user');
|
||||
|
||||
cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();
|
||||
|
||||
|
||||
@@ -105,12 +105,6 @@ In some places (like China), the ISP blocks not only the DNS resolution but also
|
||||
|
||||
You can configure Jellyseerr to use a proxy with the [HTTP(S) Proxy](/using-jellyseerr/settings/general#https-proxy) setting.
|
||||
|
||||
### Option 3: Force IPV4 resolution first
|
||||
|
||||
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
|
||||
|
||||
You can try to force the resolution to use IPV4 first by going to `Settings > Networking > Advanced Networking` and enabling `Force IPv4 Resolution First` setting and restarting Jellyseerr.
|
||||
|
||||
### Option 4: Check that your server can reach TMDB API
|
||||
|
||||
Make sure that your server can reach the TMDB API by running the following command:
|
||||
|
||||
@@ -3909,8 +3909,14 @@ paths:
|
||||
name: sort
|
||||
schema:
|
||||
type: string
|
||||
enum: [created, updated, requests, displayname]
|
||||
enum: [created, updated, requests, displayname, usertype, role]
|
||||
default: created
|
||||
- in: query
|
||||
name: sortDirection
|
||||
schema:
|
||||
type: string
|
||||
enum: [asc, desc]
|
||||
default: desc
|
||||
- in: query
|
||||
name: q
|
||||
required: false
|
||||
|
||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -15811,7 +15811,7 @@ snapshots:
|
||||
debug: 4.3.5
|
||||
enhanced-resolve: 5.17.0
|
||||
eslint: 8.35.0
|
||||
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0)
|
||||
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0)
|
||||
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.54.0(eslint@8.35.0)(typescript@4.9.5))(eslint@8.35.0)
|
||||
fast-glob: 3.3.2
|
||||
get-tsconfig: 4.7.5
|
||||
@@ -15833,7 +15833,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0):
|
||||
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0):
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
optionalDependencies:
|
||||
|
||||
@@ -37,8 +37,6 @@ class ExternalAPI {
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
this.axios.interceptors.request = axios.interceptors.request;
|
||||
this.axios.interceptors.response = axios.interceptors.response;
|
||||
|
||||
if (options.rateLimit) {
|
||||
this.axios = rateLimit(this.axios, {
|
||||
|
||||
@@ -123,8 +123,6 @@ class TautulliAPI {
|
||||
}${settings.urlBase ?? ''}`,
|
||||
params: { apikey: settings.apiKey },
|
||||
});
|
||||
this.axios.interceptors.request = axios.interceptors.request;
|
||||
this.axios.interceptors.response = axios.interceptors.response;
|
||||
}
|
||||
|
||||
public async getInfo(): Promise<TautulliInfo> {
|
||||
|
||||
@@ -28,7 +28,6 @@ import { getAppVersion } from '@server/utils/appVersion';
|
||||
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
import axios from 'axios';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
@@ -36,8 +35,6 @@ import express from 'express';
|
||||
import * as OpenApiValidator from 'express-openapi-validator';
|
||||
import type { Store } from 'express-session';
|
||||
import session from 'express-session';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import next from 'next';
|
||||
import path from 'path';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
@@ -76,11 +73,6 @@ app
|
||||
const settings = await getSettings().load();
|
||||
restartFlag.initializeSettings(settings);
|
||||
|
||||
if (settings.network.forceIpv4First) {
|
||||
axios.defaults.httpAgent = new http.Agent({ family: 4 });
|
||||
axios.defaults.httpsAgent = new https.Agent({ family: 4 });
|
||||
}
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.network.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.network.proxy);
|
||||
|
||||
@@ -150,8 +150,6 @@ class ImageProxy {
|
||||
baseURL: baseUrl,
|
||||
headers: options.headers,
|
||||
});
|
||||
this.axios.interceptors.request = axios.interceptors.request;
|
||||
this.axios.interceptors.response = axios.interceptors.response;
|
||||
|
||||
if (options.rateLimitOptions) {
|
||||
this.axios = rateLimit(this.axios, options.rateLimitOptions);
|
||||
|
||||
@@ -140,7 +140,6 @@ export interface MainSettings {
|
||||
|
||||
export interface NetworkSettings {
|
||||
csrfProtection: boolean;
|
||||
forceIpv4First: boolean;
|
||||
trustProxy: boolean;
|
||||
proxy: ProxySettings;
|
||||
}
|
||||
@@ -545,7 +544,6 @@ class Settings {
|
||||
},
|
||||
network: {
|
||||
csrfProtection: false,
|
||||
forceIpv4First: false,
|
||||
trustProxy: false,
|
||||
proxy: {
|
||||
enabled: false,
|
||||
|
||||
@@ -42,6 +42,9 @@ router.get('/', async (req, res, next) => {
|
||||
: Math.max(10, includeIds.length);
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
|
||||
const sortDirection =
|
||||
(req.query.sortDirection as string) === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
let query = getRepository(User).createQueryBuilder('user');
|
||||
|
||||
if (q) {
|
||||
@@ -56,28 +59,31 @@ router.get('/', async (req, res, next) => {
|
||||
}
|
||||
|
||||
switch (req.query.sort) {
|
||||
case 'created':
|
||||
query = query.orderBy('user.createdAt', sortDirection);
|
||||
break;
|
||||
case 'updated':
|
||||
query = query.orderBy('user.updatedAt', 'DESC');
|
||||
query = query.orderBy('user.updatedAt', sortDirection);
|
||||
break;
|
||||
case 'displayname':
|
||||
query = query
|
||||
.addSelect(
|
||||
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
||||
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
||||
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
||||
"user"."email"
|
||||
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
||||
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
||||
"user"."email"
|
||||
ELSE
|
||||
LOWER(user.jellyfinUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.jellyfinUsername)
|
||||
LOWER(user.plexUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.jellyfinUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.username)
|
||||
END`,
|
||||
LOWER(user.username)
|
||||
END`,
|
||||
'displayname_sort_key'
|
||||
)
|
||||
.orderBy('displayname_sort_key', 'ASC');
|
||||
.orderBy('displayname_sort_key', sortDirection);
|
||||
break;
|
||||
case 'requests':
|
||||
query = query
|
||||
@@ -87,10 +93,16 @@ router.get('/', async (req, res, next) => {
|
||||
.from(MediaRequest, 'request')
|
||||
.where('request.requestedBy.id = user.id');
|
||||
}, 'request_count')
|
||||
.orderBy('request_count', 'DESC');
|
||||
.orderBy('request_count', sortDirection);
|
||||
break;
|
||||
case 'usertype':
|
||||
query = query.orderBy('user.userType', sortDirection);
|
||||
break;
|
||||
case 'role':
|
||||
query = query.orderBy('user.permissions', sortDirection);
|
||||
break;
|
||||
default:
|
||||
query = query.orderBy('user.id', 'ASC');
|
||||
query = query.orderBy('user.id', sortDirection);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,12 @@ export default async function createCustomProxyAgent(
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const proxyUrl = `${proxySettings.useSsl ? 'https' : 'http'}://${
|
||||
proxySettings.hostname
|
||||
}:${proxySettings.port}`;
|
||||
const proxyUrl =
|
||||
(proxySettings.useSsl ? 'https://' : 'http://') +
|
||||
proxySettings.hostname +
|
||||
':' +
|
||||
proxySettings.port;
|
||||
|
||||
const proxyAgent = new ProxyAgent({
|
||||
uri: proxyUrl,
|
||||
token,
|
||||
@@ -67,17 +70,10 @@ export default async function createCustomProxyAgent(
|
||||
|
||||
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
||||
|
||||
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, {
|
||||
headers: token ? { 'proxy-authorization': token } : undefined,
|
||||
});
|
||||
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, {
|
||||
headers: token ? { 'proxy-authorization': token } : undefined,
|
||||
});
|
||||
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl);
|
||||
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl);
|
||||
axios.interceptors.request.use((config) => {
|
||||
const url = config.baseURL
|
||||
? new URL(config.baseURL + (config.url || ''))
|
||||
: config.url;
|
||||
if (url && skipUrl(url)) {
|
||||
if (config.url && skipUrl(config.url)) {
|
||||
config.httpAgent = false;
|
||||
config.httpsAgent = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import {
|
||||
@@ -36,7 +36,7 @@ ForwardedLink.displayName = 'ForwardedLink';
|
||||
|
||||
const UserDropdown = () => {
|
||||
const intl = useIntl();
|
||||
const { user, revalidate, hasPermission } = useUser();
|
||||
const { user, revalidate } = useUser();
|
||||
|
||||
const logout = async () => {
|
||||
const response = await axios.post('/api/v1/auth/logout');
|
||||
@@ -118,14 +118,7 @@ const UserDropdown = () => {
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<ForwardedLink
|
||||
href={
|
||||
hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? `/users/${user?.id}/requests?filter=all`
|
||||
: '/requests'
|
||||
}
|
||||
href={`/users/${user?.id}/requests?filter=all`}
|
||||
className={`flex items-center rounded px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out ${
|
||||
active
|
||||
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import RequestModal from '@app/components/RequestModal';
|
||||
import useRequestOverride from '@app/hooks/useRequestOverride';
|
||||
@@ -96,58 +95,36 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
||||
<div className="white mb-1 flex flex-nowrap">
|
||||
<span className="flex w-40 items-center truncate md:w-auto">
|
||||
<Tooltip content={intl.formatMessage(messages.requestedby)}>
|
||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<Tooltip content={intl.formatMessage(messages.requestedby)}>
|
||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
<Link
|
||||
href={
|
||||
request.requestedBy.id === user?.id
|
||||
? '/profile'
|
||||
: `/users/${request.requestedBy.id}`
|
||||
}
|
||||
className="flex items-center font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={request.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</span>
|
||||
{request.requestedBy.displayName}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
{request.modifiedBy && (
|
||||
<div className="flex flex-nowrap">
|
||||
<span className="flex w-40 items-center truncate md:w-auto">
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.lastmodifiedby)}
|
||||
>
|
||||
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<Tooltip content={intl.formatMessage(messages.lastmodifiedby)}>
|
||||
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
<Link
|
||||
href={
|
||||
request.modifiedBy.id === user?.id
|
||||
? '/profile'
|
||||
: `/users/${request.modifiedBy.id}`
|
||||
}
|
||||
className="flex items-center font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<CachedImage
|
||||
type="avatar"
|
||||
src={request.modifiedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</span>
|
||||
{request.modifiedBy.displayName}
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
@@ -42,9 +42,6 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
|
||||
networkDisclaimer:
|
||||
'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.',
|
||||
docs: 'documentation',
|
||||
forceIpv4First: 'Force IPv4 Resolution First',
|
||||
forceIpv4FirstTip:
|
||||
'Force Jellyseerr to resolve IPv4 addresses first instead of IPv6',
|
||||
});
|
||||
|
||||
const SettingsNetwork = () => {
|
||||
@@ -89,7 +86,6 @@ const SettingsNetwork = () => {
|
||||
<Formik
|
||||
initialValues={{
|
||||
csrfProtection: data?.csrfProtection,
|
||||
forceIpv4First: data?.forceIpv4First,
|
||||
trustProxy: data?.trustProxy,
|
||||
proxyEnabled: data?.proxy?.enabled,
|
||||
proxyHostname: data?.proxy?.hostname,
|
||||
@@ -106,7 +102,6 @@ const SettingsNetwork = () => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/network', {
|
||||
csrfProtection: values.csrfProtection,
|
||||
forceIpv4First: values.forceIpv4First,
|
||||
trustProxy: values.trustProxy,
|
||||
proxy: {
|
||||
enabled: values.proxyEnabled,
|
||||
@@ -198,29 +193,6 @@ const SettingsNetwork = () => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="forceIpv4First" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.forceIpv4First)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<SettingsBadge badgeType="experimental" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.forceIpv4FirstTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="forceIpv4First"
|
||||
name="forceIpv4First"
|
||||
onChange={() => {
|
||||
setFieldValue('forceIpv4First', !values.forceIpv4First);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyEnabled" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
|
||||
@@ -19,8 +19,11 @@ import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import {
|
||||
BarsArrowDownIcon,
|
||||
BarsArrowUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpIcon,
|
||||
InboxArrowDownIcon,
|
||||
PencilIcon,
|
||||
UserPlusIcon,
|
||||
@@ -77,14 +80,27 @@ const messages = defineMessages('components.UserList', {
|
||||
autogeneratepasswordTip: 'Email a server-generated password to the user',
|
||||
validationUsername: 'You must provide an username',
|
||||
validationEmail: 'Email required',
|
||||
sortCreated: 'Join Date',
|
||||
sortDisplayName: 'Display Name',
|
||||
sortRequests: 'Request Count',
|
||||
sortBy: 'Sort by {field}',
|
||||
sortByUser: 'Sort by username',
|
||||
sortByRequests: 'Sort by number of requests',
|
||||
sortByType: 'Sort by account type',
|
||||
sortByRole: 'Sort by user role',
|
||||
sortByJoined: 'Sort by join date',
|
||||
toggleSortDirection: 'Click again to sort {direction}',
|
||||
ascending: 'ascending',
|
||||
descending: 'descending',
|
||||
localLoginDisabled:
|
||||
'The <strong>Enable Local Sign-In</strong> setting is currently disabled.',
|
||||
});
|
||||
|
||||
type Sort = 'created' | 'updated' | 'requests' | 'displayname';
|
||||
type Sort =
|
||||
| 'created'
|
||||
| 'updated'
|
||||
| 'requests'
|
||||
| 'displayname'
|
||||
| 'usertype'
|
||||
| 'role';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const UserList = () => {
|
||||
const intl = useIntl();
|
||||
@@ -94,10 +110,12 @@ const UserList = () => {
|
||||
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
|
||||
const [currentSort, setCurrentSort] = useState<Sort>('displayname');
|
||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||
const [localUsers, setLocalUsers] = useState<User[]>([]);
|
||||
|
||||
const page = router.query.page ? Number(router.query.page) : 1;
|
||||
const pageIndex = page - 1;
|
||||
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -106,9 +124,59 @@ const UserList = () => {
|
||||
} = useSWR<UserResultsResponse>(
|
||||
`/api/v1/user?take=${currentPageSize}&skip=${
|
||||
pageIndex * currentPageSize
|
||||
}&sort=${currentSort}`
|
||||
}&sort=created&sortDirection=desc`
|
||||
);
|
||||
|
||||
const sortUsers = (
|
||||
users: User[],
|
||||
sortKey: Sort,
|
||||
direction: SortDirection
|
||||
) => {
|
||||
return [...users].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortKey) {
|
||||
case 'displayname':
|
||||
comparison = a.displayName.localeCompare(b.displayName);
|
||||
break;
|
||||
case 'requests':
|
||||
comparison = (a.requestCount ?? 0) - (b.requestCount ?? 0);
|
||||
break;
|
||||
case 'usertype':
|
||||
comparison = a.userType - b.userType;
|
||||
break;
|
||||
case 'role':
|
||||
comparison = a.permissions - b.permissions;
|
||||
break;
|
||||
case 'created':
|
||||
comparison =
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
break;
|
||||
default:
|
||||
comparison = 0;
|
||||
}
|
||||
return direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.results) {
|
||||
setLocalUsers(sortUsers(data.results, currentSort, sortDirection));
|
||||
}
|
||||
}, [data, currentSort, sortDirection]);
|
||||
|
||||
const handleSortChange = (sortKey: Sort) => {
|
||||
const newSortDirection =
|
||||
currentSort === sortKey
|
||||
? sortDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: 'desc';
|
||||
|
||||
setCurrentSort(sortKey);
|
||||
setSortDirection(newSortDirection);
|
||||
setLocalUsers(sortUsers(localUsers, sortKey, newSortDirection));
|
||||
};
|
||||
|
||||
const [isDeleting, setDeleting] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
@@ -133,6 +201,9 @@ const UserList = () => {
|
||||
|
||||
setCurrentSort(filterSettings.currentSort);
|
||||
setCurrentPageSize(filterSettings.currentPageSize);
|
||||
if (filterSettings.sortDirection) {
|
||||
setSortDirection(filterSettings.sortDirection);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -142,9 +213,74 @@ const UserList = () => {
|
||||
JSON.stringify({
|
||||
currentSort,
|
||||
currentPageSize,
|
||||
sortDirection,
|
||||
})
|
||||
);
|
||||
}, [currentSort, currentPageSize]);
|
||||
}, [currentSort, currentPageSize, sortDirection]);
|
||||
|
||||
const SortableColumnHeader = ({
|
||||
sortKey,
|
||||
currentSort,
|
||||
sortDirection,
|
||||
onSortChange,
|
||||
children,
|
||||
}: {
|
||||
sortKey: Sort;
|
||||
currentSort: Sort;
|
||||
sortDirection: SortDirection;
|
||||
onSortChange: (sortKey: Sort) => void;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const getTooltip = () => {
|
||||
if (currentSort === sortKey) {
|
||||
return intl.formatMessage(messages.toggleSortDirection, {
|
||||
direction:
|
||||
sortDirection === 'asc'
|
||||
? intl.formatMessage(messages.descending)
|
||||
: intl.formatMessage(messages.ascending),
|
||||
});
|
||||
}
|
||||
|
||||
switch (sortKey) {
|
||||
case 'displayname':
|
||||
return intl.formatMessage(messages.sortByUser);
|
||||
case 'requests':
|
||||
return intl.formatMessage(messages.sortByRequests);
|
||||
case 'usertype':
|
||||
return intl.formatMessage(messages.sortByType);
|
||||
case 'role':
|
||||
return intl.formatMessage(messages.sortByRole);
|
||||
case 'created':
|
||||
return intl.formatMessage(messages.sortByJoined);
|
||||
default:
|
||||
return intl.formatMessage(messages.sortBy, { field: sortKey });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Table.TH
|
||||
className="cursor-pointer hover:bg-gray-700"
|
||||
onClick={() => onSortChange(sortKey)}
|
||||
data-testid={`column-header-${sortKey}`}
|
||||
title={getTooltip()}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>{children}</span>
|
||||
{currentSort === sortKey && (
|
||||
<span className="ml-1">
|
||||
{sortDirection === 'asc' ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Table.TH>
|
||||
);
|
||||
};
|
||||
|
||||
const isUserPermsEditable = (userId: number) =>
|
||||
userId !== 1 && userId !== currentUser?.id;
|
||||
@@ -541,28 +677,47 @@ const UserList = () => {
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-grow lg:mb-0 lg:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||
<BarsArrowDownIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100"
|
||||
onClick={() =>
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
title={
|
||||
sortDirection === 'asc'
|
||||
? intl.formatMessage(messages.descending)
|
||||
: intl.formatMessage(messages.ascending)
|
||||
}
|
||||
>
|
||||
{sortDirection === 'asc' ? (
|
||||
<BarsArrowUpIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<BarsArrowDownIcon className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
<select
|
||||
id="sort"
|
||||
name="sort"
|
||||
onChange={(e) => {
|
||||
setCurrentSort(e.target.value as Sort);
|
||||
router.push(router.pathname);
|
||||
}}
|
||||
value={currentSort}
|
||||
className="rounded-r-only"
|
||||
>
|
||||
<option value="created">
|
||||
{intl.formatMessage(messages.sortCreated)}
|
||||
<option value="displayname">
|
||||
{intl.formatMessage(messages.username)}
|
||||
</option>
|
||||
<option value="requests">
|
||||
{intl.formatMessage(messages.sortRequests)}
|
||||
{intl.formatMessage(messages.totalrequests)}
|
||||
</option>
|
||||
<option value="displayname">
|
||||
{intl.formatMessage(messages.sortDisplayName)}
|
||||
<option value="usertype">
|
||||
{intl.formatMessage(messages.accounttype)}
|
||||
</option>
|
||||
<option value="role">{intl.formatMessage(messages.role)}</option>
|
||||
<option value="created">
|
||||
{intl.formatMessage(messages.created)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -584,11 +739,46 @@ const UserList = () => {
|
||||
/>
|
||||
)}
|
||||
</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.user)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.totalrequests)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.accounttype)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.role)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.created)}</Table.TH>
|
||||
<SortableColumnHeader
|
||||
sortKey="displayname"
|
||||
currentSort={currentSort}
|
||||
sortDirection={sortDirection}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
{intl.formatMessage(messages.user)}
|
||||
</SortableColumnHeader>
|
||||
<SortableColumnHeader
|
||||
sortKey="requests"
|
||||
currentSort={currentSort}
|
||||
sortDirection={sortDirection}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
{intl.formatMessage(messages.totalrequests)}
|
||||
</SortableColumnHeader>
|
||||
<SortableColumnHeader
|
||||
sortKey="usertype"
|
||||
currentSort={currentSort}
|
||||
sortDirection={sortDirection}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
{intl.formatMessage(messages.accounttype)}
|
||||
</SortableColumnHeader>
|
||||
<SortableColumnHeader
|
||||
sortKey="role"
|
||||
currentSort={currentSort}
|
||||
sortDirection={sortDirection}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
{intl.formatMessage(messages.role)}
|
||||
</SortableColumnHeader>
|
||||
<SortableColumnHeader
|
||||
sortKey="created"
|
||||
currentSort={currentSort}
|
||||
sortDirection={sortDirection}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
{intl.formatMessage(messages.created)}
|
||||
</SortableColumnHeader>
|
||||
<Table.TH className="text-right">
|
||||
{(data.results ?? []).length > 1 && (
|
||||
<Button
|
||||
@@ -604,7 +794,7 @@ const UserList = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<Table.TBody>
|
||||
{data?.results.map((user) => (
|
||||
{localUsers.map((user) => (
|
||||
<tr key={`user-list-${user.id}`} data-testid="user-list-row">
|
||||
<Table.TD>
|
||||
{isUserPermsEditable(user.id) && (
|
||||
|
||||
@@ -160,12 +160,9 @@ const UserProfile = () => {
|
||||
<dd className="mt-1 text-3xl font-semibold text-white">
|
||||
<Link
|
||||
href={
|
||||
currentHasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? `/users/${user?.id}/requests?filter=all`
|
||||
: '/requests'
|
||||
user.id === currentUser?.id
|
||||
? '/profile/requests?filter=all'
|
||||
: `/users/${user?.id}/requests?filter=all`
|
||||
}
|
||||
>
|
||||
{intl.formatNumber(user.requestCount)}
|
||||
@@ -296,12 +293,9 @@ const UserProfile = () => {
|
||||
<div className="slider-header">
|
||||
<Link
|
||||
href={
|
||||
currentHasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? `/users/${user?.id}/requests?filter=all`
|
||||
: '/requests'
|
||||
user.id === currentUser?.id
|
||||
? '/profile/requests?filter=all'
|
||||
: `/users/${user?.id}/requests?filter=all`
|
||||
}
|
||||
className="slider-title"
|
||||
>
|
||||
|
||||
@@ -978,13 +978,10 @@
|
||||
"components.Settings.SettingsMain.validationUrl": "You must provide a valid URL",
|
||||
"components.Settings.SettingsMain.validationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||
"components.Settings.SettingsMain.youtubeUrl": "YouTube URL",
|
||||
"components.Settings.SettingsMain.youtubeUrlTip": "Base URL for YouTube videos if a self-hosted YouTube instance is used.",
|
||||
"components.Settings.SettingsNetwork.csrfProtection": "Enable CSRF Protection",
|
||||
"components.Settings.SettingsNetwork.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
|
||||
"components.Settings.SettingsNetwork.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
|
||||
"components.Settings.SettingsNetwork.docs": "documentation",
|
||||
"components.Settings.SettingsNetwork.forceIpv4First": "Force IPv4 Resolution First",
|
||||
"components.Settings.SettingsNetwork.forceIpv4FirstTip": "Force Jellyseerr to resolve IPv4 addresses first instead of IPv6",
|
||||
"components.Settings.SettingsNetwork.network": "Network",
|
||||
"components.Settings.SettingsNetwork.networkDisclaimer": "Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.",
|
||||
"components.Settings.SettingsNetwork.networksettings": "Network Settings",
|
||||
@@ -1216,7 +1213,7 @@
|
||||
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
|
||||
"components.Setup.servertype": "Choose Server Type",
|
||||
"components.Setup.setup": "Setup",
|
||||
"components.Setup.signin": "Sign In",
|
||||
"components.Setup.signin": "Sign in to your account",
|
||||
"components.Setup.signinMessage": "Get started by signing in",
|
||||
"components.Setup.signinWithEmby": "Enter your Emby details",
|
||||
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
||||
@@ -1285,6 +1282,7 @@
|
||||
"components.TvDetails.watchtrailer": "Watch Trailer",
|
||||
"components.UserList.accounttype": "Type",
|
||||
"components.UserList.admin": "Admin",
|
||||
"components.UserList.ascending": "ascending",
|
||||
"components.UserList.autogeneratepassword": "Automatically Generate Password",
|
||||
"components.UserList.autogeneratepasswordTip": "Email a server-generated password to the user",
|
||||
"components.UserList.bulkedit": "Bulk Edit",
|
||||
@@ -1294,6 +1292,7 @@
|
||||
"components.UserList.creating": "Creating…",
|
||||
"components.UserList.deleteconfirm": "Are you sure you want to delete this user? All of their request data will be permanently removed.",
|
||||
"components.UserList.deleteuser": "Delete User",
|
||||
"components.UserList.descending": "descending",
|
||||
"components.UserList.edituser": "Edit User Permissions",
|
||||
"components.UserList.email": "Email Address",
|
||||
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!",
|
||||
@@ -1315,9 +1314,13 @@
|
||||
"components.UserList.passwordinfodescription": "Configure an application URL and enable email notifications to allow automatic password generation.",
|
||||
"components.UserList.plexuser": "Plex User",
|
||||
"components.UserList.role": "Role",
|
||||
"components.UserList.sortCreated": "Join Date",
|
||||
"components.UserList.sortDisplayName": "Display Name",
|
||||
"components.UserList.sortRequests": "Request Count",
|
||||
"components.UserList.sortBy": "Sort by {field}",
|
||||
"components.UserList.sortByJoined": "Sort by join date",
|
||||
"components.UserList.sortByRequests": "Sort by number of requests",
|
||||
"components.UserList.sortByRole": "Sort by user role",
|
||||
"components.UserList.sortByType": "Sort by account type",
|
||||
"components.UserList.sortByUser": "Sort by username",
|
||||
"components.UserList.toggleSortDirection": "Click again to sort {direction}",
|
||||
"components.UserList.totalrequests": "Requests",
|
||||
"components.UserList.user": "User",
|
||||
"components.UserList.usercreatedfailed": "Something went wrong while creating the user.",
|
||||
|
||||
8
src/pages/profile/requests.tsx
Normal file
8
src/pages/profile/requests.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import RequestList from '@app/components/RequestList';
|
||||
import type { NextPage } from 'next';
|
||||
|
||||
const UserRequestsPage: NextPage = () => {
|
||||
return <RequestList />;
|
||||
};
|
||||
|
||||
export default UserRequestsPage;
|
||||
Reference in New Issue
Block a user