Compare commits

..

1 Commits

Author SHA1 Message Date
gauthier-th
b92fe7821e feat: test force Ipv4 for Axios 2025-06-11 11:15:58 +02:00
18 changed files with 114 additions and 373 deletions

View File

@@ -36,7 +36,7 @@ describe('User List', () => {
cy.get('#email').type(testUser.emailAddress);
cy.get('#password').type(testUser.password);
cy.intercept('/api/v1/user*').as('user');
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').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*').as('user');
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();

View File

@@ -3909,14 +3909,8 @@ paths:
name: sort
schema:
type: string
enum: [created, updated, requests, displayname, usertype, role]
enum: [created, updated, requests, displayname]
default: created
- in: query
name: sortDirection
schema:
type: string
enum: [asc, desc]
default: desc
- in: query
name: q
required: false
@@ -4113,7 +4107,7 @@ paths:
type: string
userAgent:
type: string
/user/{userId}/pushSubscription/{endpoint}:
/user/{userId}/pushSubscription/{key}:
get:
summary: Get web push notification settings for a user
description: |
@@ -4127,7 +4121,7 @@ paths:
schema:
type: number
- in: path
name: endpoint
name: key
required: true
schema:
type: string
@@ -4159,7 +4153,7 @@ paths:
schema:
type: number
- in: path
name: endpoint
name: key
required: true
schema:
type: string

View File

@@ -43,8 +43,8 @@
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30",
"@types/ua-parser-js": "^0.7.36",
"@types/wink-jaro-distance": "^2.0.2",
"@types/ua-parser-js": "^0.7.36",
"ace-builds": "1.15.2",
"axios": "1.3.4",
"axios-rate-limit": "1.3.0",
@@ -65,8 +65,6 @@
"express-session": "1.17.3",
"formik": "^2.4.6",
"gravatar-url": "3.1.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"lodash": "4.17.21",
"mime": "3",
"next": "^14.2.25",
@@ -103,8 +101,8 @@
"swr": "2.2.5",
"tailwind-merge": "^2.6.0",
"typeorm": "0.3.12",
"ua-parser-js": "^1.0.35",
"undici": "^7.3.0",
"ua-parser-js": "^1.0.35",
"web-push": "3.5.0",
"wink-jaro-distance": "^2.0.0",
"winston": "3.8.2",

32
pnpm-lock.yaml generated
View File

@@ -107,12 +107,6 @@ importers:
gravatar-url:
specifier: 3.1.0
version: 3.1.0
http-proxy-agent:
specifier: ^7.0.2
version: 7.0.2
https-proxy-agent:
specifier: ^7.0.6
version: 7.0.6
lodash:
specifier: 4.17.21
version: 4.17.21
@@ -3632,10 +3626,6 @@ packages:
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==}
engines: {node: '>= 14'}
agent-base@7.1.3:
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
engines: {node: '>= 14'}
agentkeepalive@4.5.0:
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
engines: {node: '>= 8.0.0'}
@@ -5798,8 +5788,8 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
https-proxy-agent@7.0.5:
resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
engines: {node: '>= 14'}
human-signals@1.1.1:
@@ -11132,7 +11122,7 @@ snapshots:
'@babel/helper-split-export-declaration': 7.24.7
'@babel/parser': 7.24.7
'@babel/types': 7.24.7
debug: 4.4.0(supports-color@5.5.0)
debug: 4.3.5
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -13349,7 +13339,7 @@ snapshots:
fs-extra: 11.2.0
globby: 11.1.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.5
issue-parser: 6.0.0
lodash: 4.17.21
mime: 3.0.0
@@ -13971,7 +13961,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 7.2.0
'@typescript-eslint/visitor-keys': 7.2.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.3.5
globby: 11.1.0
is-glob: 4.0.3
minimatch: 9.0.3
@@ -14062,12 +14052,10 @@ snapshots:
agent-base@7.1.1:
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.3.5
transitivePeerDependencies:
- supports-color
agent-base@7.1.3: {}
agentkeepalive@4.5.0:
dependencies:
humanize-ms: 1.2.1
@@ -16833,7 +16821,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.1
debug: 4.4.0(supports-color@5.5.0)
debug: 4.3.5
transitivePeerDependencies:
- supports-color
@@ -16860,10 +16848,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
https-proxy-agent@7.0.5:
dependencies:
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
agent-base: 7.1.1
debug: 4.3.5
transitivePeerDependencies:
- supports-color

View File

@@ -130,7 +130,9 @@ class JellyfinAPI extends ExternalAPI {
const safeDeviceId =
deviceId && deviceId.length > 0
? deviceId
: Buffer.from('BOT_jellyseerr').toString('base64');
: Buffer.from(`BOT_jellyseerr_fallback_${Date.now()}`).toString(
'base64'
);
let authHeaderVal: string;
if (authToken) {

View File

@@ -28,6 +28,7 @@ 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';
@@ -35,11 +36,16 @@ 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';
import YAML from 'yamljs';
axios.defaults.httpAgent = new http.Agent({ family: 4 });
axios.defaults.httpsAgent = new https.Agent({ family: 4 });
const API_SPEC_PATH = path.join(__dirname, '../jellyseerr-api.yml');
logger.info(`Starting Jellyseerr version ${getAppVersion()}`);

View File

@@ -277,14 +277,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
select: { id: true, jellyfinDeviceId: true },
});
let deviceId = 'BOT_jellyseerr';
if (user && user.id === 1) {
// Admin is always BOT_jellyseerr
deviceId = 'BOT_jellyseerr';
} else if (user && user.jellyfinDeviceId) {
deviceId = user.jellyfinDeviceId;
} else if (body.username) {
deviceId = Buffer.from(`BOT_jellyseerr_${body.username}`).toString(
let deviceId = '';
if (user) {
deviceId = user.jellyfinDeviceId ?? '';
} else {
deviceId = Buffer.from(`BOT_jellyseerr_${body.username ?? ''}`).toString(
'base64'
);
}

View File

@@ -23,7 +23,7 @@ async function initAvatarImageProxy() {
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const deviceId = admin?.jellyfinDeviceId || 'BOT_jellyseerr';
const deviceId = admin?.jellyfinDeviceId;
const authToken = getSettings().jellyfin.apiKey;
_avatarImageProxy = new ImageProxy('avatar', '', {
headers: {

View File

@@ -42,9 +42,6 @@ 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) {
@@ -59,31 +56,28 @@ 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', sortDirection);
query = query.orderBy('user.updatedAt', 'DESC');
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"
ELSE
LOWER(user.jellyfinUsername)
END)
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.plexUsername)
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.username)
END`,
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.username)
END`,
'displayname_sort_key'
)
.orderBy('displayname_sort_key', sortDirection);
.orderBy('displayname_sort_key', 'ASC');
break;
case 'requests':
query = query
@@ -93,16 +87,10 @@ router.get('/', async (req, res, next) => {
.from(MediaRequest, 'request')
.where('request.requestedBy.id = user.id');
}, 'request_count')
.orderBy('request_count', sortDirection);
break;
case 'usertype':
query = query.orderBy('user.userType', sortDirection);
break;
case 'role':
query = query.orderBy('user.permissions', sortDirection);
.orderBy('request_count', 'DESC');
break;
default:
query = query.orderBy('user.id', sortDirection);
query = query.orderBy('user.id', 'ASC');
break;
}
@@ -252,8 +240,8 @@ router.get<{ userId: number }>(
}
);
router.get<{ userId: number; endpoint: string }>(
'/:userId/pushSubscription/:endpoint',
router.get<{ userId: number; key: string }>(
'/:userId/pushSubscription/:key',
async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
@@ -264,7 +252,7 @@ router.get<{ userId: number; endpoint: string }>(
},
where: {
user: { id: req.params.userId },
endpoint: req.params.endpoint,
p256dh: req.params.key,
},
});
@@ -275,8 +263,8 @@ router.get<{ userId: number; endpoint: string }>(
}
);
router.delete<{ userId: number; endpoint: string }>(
'/:userId/pushSubscription/:endpoint',
router.delete<{ userId: number; key: string }>(
'/:userId/pushSubscription/:key',
async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
@@ -287,7 +275,7 @@ router.delete<{ userId: number; endpoint: string }>(
},
where: {
user: { id: req.params.userId },
endpoint: req.params.endpoint,
p256dh: req.params.key,
},
});
@@ -296,7 +284,7 @@ router.delete<{ userId: number; endpoint: string }>(
} catch (e) {
logger.error('Something went wrong deleting the user push subcription', {
label: 'API',
endpoint: req.params.endpoint,
key: req.params.key,
errorMessage: e.message,
});
return next({

View File

@@ -421,9 +421,7 @@ userSettingsRoutes.post<{ username: string; password: string }>(
const hostname = getHostname();
const deviceId = Buffer.from(
req.user?.id === 1
? 'BOT_jellyseerr'
: `BOT_jellyseerr_${req.user.username ?? ''}`
`BOT_jellyseerr_${req.user.username ?? ''}`
).toString('base64');
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);

View File

@@ -1,8 +1,6 @@
import type { ProxySettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import type { Dispatcher } from 'undici';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
@@ -56,29 +54,17 @@ export default async function createCustomProxyAgent(
: undefined;
try {
const proxyUrl =
(proxySettings.useSsl ? 'https://' : 'http://') +
proxySettings.hostname +
':' +
proxySettings.port;
const proxyAgent = new ProxyAgent({
uri: proxyUrl,
uri:
(proxySettings.useSsl ? 'https://' : 'http://') +
proxySettings.hostname +
':' +
proxySettings.port,
token,
keepAliveTimeout: 5000,
});
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl);
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl);
axios.interceptors.request.use((config) => {
if (config.url && skipUrl(config.url)) {
config.httpAgent = false;
config.httpsAgent = false;
}
return config;
});
} catch (e) {
logger.error('Failed to connect to the proxy: ' + e.message, {
label: 'Proxy',

View File

@@ -74,14 +74,6 @@ const MediaSlider = ({
);
}
if (settings.currentSettings.hideBlacklisted) {
titles = titles.filter(
(i) =>
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
i.mediaInfo?.status !== MediaStatus.BLACKLISTED
);
}
useEffect(() => {
if (
titles.length < 24 &&

View File

@@ -6,7 +6,6 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { isValidURL } from '@app/utils/urlValidationHelper';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import type { NotificationAgentNtfy } from '@server/lib/settings';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
@@ -45,7 +44,7 @@ const NotificationsNtfy = () => {
data,
error,
mutate: revalidate,
} = useSWR<NotificationAgentNtfy>('/api/v1/settings/notifications/ntfy');
} = useSWR('/api/v1/settings/notifications/ntfy');
const NotificationsNtfySchema = Yup.object().shape({
url: Yup.string()
@@ -79,15 +78,15 @@ const NotificationsNtfy = () => {
return (
<Formik
initialValues={{
enabled: data?.enabled,
types: data?.types,
url: data?.options.url,
topic: data?.options.topic,
authMethodUsernamePassword: data?.options.authMethodUsernamePassword,
username: data?.options.username,
password: data?.options.password,
authMethodToken: data?.options.authMethodToken,
token: data?.options.token,
enabled: data.enabled,
types: data.types,
url: data.options.url,
topic: data.options.topic,
authMethodUsernamePassword: data.options.authMethod,
username: data.options.username,
password: data.options.password,
authMethodToken: data.options.authMethodToken,
token: data.options.token,
}}
validationSchema={NotificationsNtfySchema}
onSubmit={async (values) => {
@@ -303,7 +302,7 @@ const NotificationsNtfy = () => {
</div>
)}
<NotificationTypeSelector
currentTypes={values.enabled ? values.types || 0 : 0}
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');

View File

@@ -66,8 +66,6 @@ const messages = defineMessages('components.Settings.SettingsMain', {
enableSpecialEpisodes: 'Allow Special Episodes Requests',
locale: 'Display Language',
youtubeUrl: 'YouTube URL',
youtubeUrlTip:
'Base URL for YouTube videos if a self-hosted YouTube instance is used.',
validationUrl: 'You must provide a valid URL',
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
});
@@ -538,9 +536,6 @@ const SettingsMain = () => {
<div className="form-row">
<label htmlFor="youtubeUrl" className="text-label">
{intl.formatMessage(messages.youtubeUrl)}
<span className="label-tip">
{intl.formatMessage(messages.youtubeUrlTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">

View File

@@ -19,11 +19,8 @@ import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import {
BarsArrowDownIcon,
BarsArrowUpIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
InboxArrowDownIcon,
PencilIcon,
UserPlusIcon,
@@ -80,27 +77,14 @@ const messages = defineMessages('components.UserList', {
autogeneratepasswordTip: 'Email a server-generated password to the user',
validationUsername: 'You must provide an username',
validationEmail: 'Email required',
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',
sortCreated: 'Join Date',
sortDisplayName: 'Display Name',
sortRequests: 'Request Count',
localLoginDisabled:
'The <strong>Enable Local Sign-In</strong> setting is currently disabled.',
});
type Sort =
| 'created'
| 'updated'
| 'requests'
| 'displayname'
| 'usertype'
| 'role';
type SortDirection = 'asc' | 'desc';
type Sort = 'created' | 'updated' | 'requests' | 'displayname';
const UserList = () => {
const intl = useIntl();
@@ -110,12 +94,10 @@ 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,
@@ -124,59 +106,9 @@ const UserList = () => {
} = useSWR<UserResultsResponse>(
`/api/v1/user?take=${currentPageSize}&skip=${
pageIndex * currentPageSize
}&sort=created&sortDirection=desc`
}&sort=${currentSort}`
);
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<{
@@ -201,9 +133,6 @@ const UserList = () => {
setCurrentSort(filterSettings.currentSort);
setCurrentPageSize(filterSettings.currentPageSize);
if (filterSettings.sortDirection) {
setSortDirection(filterSettings.sortDirection);
}
}
}, []);
@@ -213,74 +142,9 @@ const UserList = () => {
JSON.stringify({
currentSort,
currentPageSize,
sortDirection,
})
);
}, [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>
);
};
}, [currentSort, currentPageSize]);
const isUserPermsEditable = (userId: number) =>
userId !== 1 && userId !== currentUser?.id;
@@ -677,47 +541,28 @@ const UserList = () => {
</span>
</Button>
</div>
<div className="mb-2 flex flex-grow lg:mb-0 lg:flex-grow-0">
<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>
<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>
<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="displayname">
{intl.formatMessage(messages.username)}
<option value="created">
{intl.formatMessage(messages.sortCreated)}
</option>
<option value="requests">
{intl.formatMessage(messages.totalrequests)}
{intl.formatMessage(messages.sortRequests)}
</option>
<option value="usertype">
{intl.formatMessage(messages.accounttype)}
</option>
<option value="role">{intl.formatMessage(messages.role)}</option>
<option value="created">
{intl.formatMessage(messages.created)}
<option value="displayname">
{intl.formatMessage(messages.sortDisplayName)}
</option>
</select>
</div>
@@ -739,46 +584,11 @@ const UserList = () => {
/>
)}
</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>{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>
<Table.TH className="text-right">
{(data.results ?? []).length > 1 && (
<Button
@@ -794,7 +604,7 @@ const UserList = () => {
</tr>
</thead>
<Table.TBody>
{localUsers.map((user) => (
{data?.results.map((user) => (
<tr key={`user-list-${user.id}`} data-testid="user-list-row">
<Table.TD>
{isUserPermsEditable(user.id) && (

View File

@@ -33,14 +33,13 @@ const messages = defineMessages(
const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => {
const intl = useIntl();
const parsedUserAgent = UAParser(device.userAgent);
return (
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
<div className="relative flex w-full flex-col justify-between overflow-hidden sm:flex-row">
<div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
<div className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105">
{parsedUserAgent.device.type === 'mobile' ? (
{UAParser(device.userAgent).device.type === 'mobile' ? (
<DevicePhoneMobileIcon />
) : (
<ComputerDesktopIcon />
@@ -57,8 +56,8 @@ const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => {
: 'N/A'}
</div>
<div className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
{device.userAgent && parsedUserAgent.device.model
? parsedUserAgent.device.model
{device.userAgent
? UAParser(device.userAgent).device.model
: intl.formatMessage(messages.unknown)}
</div>
</div>
@@ -69,7 +68,7 @@ const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => {
{intl.formatMessage(messages.operatingsystem)}
</span>
<span className="flex truncate text-sm text-gray-300">
{device.userAgent ? parsedUserAgent.os.name : 'N/A'}
{device.userAgent ? UAParser(device.userAgent).os.name : 'N/A'}
</span>
</div>
<div className="card-field">
@@ -77,7 +76,9 @@ const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => {
{intl.formatMessage(messages.browser)}
</span>
<span className="flex truncate text-sm text-gray-300">
{device.userAgent ? parsedUserAgent.browser.name : 'N/A'}
{device.userAgent
? UAParser(device.userAgent).browser.name
: 'N/A'}
</span>
</div>
<div className="card-field">
@@ -85,14 +86,16 @@ const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => {
{intl.formatMessage(messages.engine)}
</span>
<span className="flex truncate text-sm text-gray-300">
{device.userAgent ? parsedUserAgent.engine.name : 'N/A'}
{device.userAgent
? UAParser(device.userAgent).engine.name
: 'N/A'}
</span>
</div>
</div>
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
<ConfirmButton
onClick={() => disablePushNotifications(device.endpoint)}
onClick={() => disablePushNotifications(device.p256dh)}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>

View File

@@ -113,7 +113,7 @@ const UserWebPushSettings = () => {
// Unsubscribes from the push manager
// Deletes/disables corresponding push subscription from database
const disablePushNotifications = async (endpoint?: string) => {
const disablePushNotifications = async (p256dh?: string) => {
if ('serviceWorker' in navigator && user?.id) {
navigator.serviceWorker.getRegistration('/sw.js').then((registration) => {
registration?.pushManager
@@ -122,21 +122,17 @@ const UserWebPushSettings = () => {
const parsedSub = JSON.parse(JSON.stringify(subscription));
await axios.delete(
`/api/v1/user/${user.id}/pushSubscription/${encodeURIComponent(
endpoint ?? parsedSub.endpoint
)}`
`/api/v1/user/${user?.id}/pushSubscription/${
p256dh ? p256dh : parsedSub.keys.p256dh
}`
);
if (
subscription &&
(endpoint === parsedSub.endpoint || !endpoint)
) {
if (subscription && (p256dh === parsedSub.keys.p256dh || !p256dh)) {
subscription.unsubscribe();
setWebPushEnabled(false);
}
addToast(
intl.formatMessage(
endpoint
p256dh
? messages.subscriptiondeleted
: messages.webpushhasbeendisabled
),
@@ -149,7 +145,7 @@ const UserWebPushSettings = () => {
.catch(function () {
addToast(
intl.formatMessage(
endpoint
p256dh
? messages.subscriptiondeleteerror
: messages.disablingwebpusherror
),
@@ -180,17 +176,12 @@ const UserWebPushSettings = () => {
const parsedKey = JSON.parse(JSON.stringify(subscription));
const currentUserPushSub =
await axios.get<UserPushSubscription>(
`/api/v1/user/${
user.id
}/pushSubscription/${encodeURIComponent(
parsedKey.endpoint
)}`
`/api/v1/user/${user.id}/pushSubscription/${parsedKey.keys.p256dh}`
);
if (currentUserPushSub.data.endpoint !== parsedKey.endpoint) {
if (currentUserPushSub.data.p256dh !== parsedKey.keys.p256dh) {
return;
}
setWebPushEnabled(true);
} else {
setWebPushEnabled(false);

View File

@@ -1282,7 +1282,6 @@
"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",
@@ -1292,7 +1291,6 @@
"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!",
@@ -1314,13 +1312,9 @@
"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.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.sortCreated": "Join Date",
"components.UserList.sortDisplayName": "Display Name",
"components.UserList.sortRequests": "Request Count",
"components.UserList.totalrequests": "Requests",
"components.UserList.user": "User",
"components.UserList.usercreatedfailed": "Something went wrong while creating the user.",