Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/lib/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export enum Permission {
RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
MANAGE_BLOCKLIST = 268435456,
REQUEST_ADVANCED_LANGUAGE = 536870912,
VIEW_BLOCKLIST = 1073741824,
}

Expand Down
13 changes: 13 additions & 0 deletions src/components/PermissionEdit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ export const messages = defineMessages('components.PermissionEdit', {
advancedrequest: 'Advanced Requests',
advancedrequestDescription:
'Grant permission to modify advanced media request options.',
advancedrequestLanguage: 'Advanced Requests: Language Profile',
advancedrequestLanguageDescription:
'Grant permission to override only the language profile on series requests.',
autorequest: 'Auto-Request',
autorequestDescription:
'Grant permission to automatically submit requests for non-4K media via Plex Watchlist.',
Expand Down Expand Up @@ -127,6 +130,16 @@ export const PermissionEdit = ({
name: intl.formatMessage(messages.advancedrequest),
description: intl.formatMessage(messages.advancedrequestDescription),
permission: Permission.REQUEST_ADVANCED,
children: [
{
id: 'advancedrequest-language',
name: intl.formatMessage(messages.advancedrequestLanguage),
description: intl.formatMessage(
messages.advancedrequestLanguageDescription
),
permission: Permission.REQUEST_ADVANCED_LANGUAGE,
},
],
},
{
id: 'viewrequests',
Expand Down
268 changes: 146 additions & 122 deletions src/components/RequestModal/AdvancedRequester/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ const AdvancedRequester = ({
currentHasPermission([Permission.MANAGE_REQUESTS]) &&
((type === 'movie' ? quota?.movie.limit : quota?.tv.limit) ?? 0) > 0;

// Users with the legacy "all-or-nothing" Advanced Request permission or
// Manage Requests see every advanced field. Users who only hold a granular
// sub-permission (e.g. REQUEST_ADVANCED_LANGUAGE) only see the matching
// field. This keeps backward compatibility while allowing per-field grants.
const canEditLegacyAdvancedFields = currentHasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_ADVANCED],
{ type: 'or' }
);
Comment on lines +118 to +121

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Gate emitted overrides, not only rendered fields.

With language-only access, this component hides server/profile/folder/tags, but the existing onChange payload still emits those hidden values and TvRequestModal forwards them. That undermines the “language only” permission split unless every hidden field is also stripped before submit.

Proposed client-side guard
   useEffect(() => {
     if (selectedServer !== null || selectedUser) {
       onChange({
-        folder: selectedFolder !== '' ? selectedFolder : undefined,
-        profile: selectedProfile !== -1 ? selectedProfile : undefined,
-        server: selectedServer ?? undefined,
+        ...(canEditLegacyAdvancedFields
+          ? {
+              folder: selectedFolder !== '' ? selectedFolder : undefined,
+              profile: selectedProfile !== -1 ? selectedProfile : undefined,
+              server: selectedServer ?? undefined,
+              tags: selectedTags,
+            }
+          : {}),
         user: selectedUser ?? undefined,
         language: selectedLanguage !== -1 ? selectedLanguage : undefined,
-        tags: selectedTags,
         ignoreQuota: isIgnoreQuotaVisible && ignoreQuota ? true : undefined,
       });
     }
   }, [
@@
     selectedTags,
     ignoreQuota,
     isIgnoreQuotaVisible,
+    canEditLegacyAdvancedFields,
   ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const canEditLegacyAdvancedFields = currentHasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_ADVANCED],
{ type: 'or' }
);
useEffect(() => {
if (selectedServer !== null || selectedUser) {
onChange({
...(canEditLegacyAdvancedFields
? {
folder: selectedFolder !== '' ? selectedFolder : undefined,
profile: selectedProfile !== -1 ? selectedProfile : undefined,
server: selectedServer ?? undefined,
tags: selectedTags,
}
: {}),
user: selectedUser ?? undefined,
language: selectedLanguage !== -1 ? selectedLanguage : undefined,
ignoreQuota: isIgnoreQuotaVisible && ignoreQuota ? true : undefined,
});
}
}, [
selectedFolder,
selectedProfile,
selectedServer,
selectedUser,
selectedLanguage,
selectedTags,
ignoreQuota,
isIgnoreQuotaVisible,
canEditLegacyAdvancedFields,
]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/RequestModal/AdvancedRequester/index.tsx` around lines 118 -
121, The permission check in AdvancedRequester should not only hide legacy
fields in the UI; it must also prevent hidden server/profile/folder/tags values
from being emitted in the onChange payload. Update AdvancedRequester’s
submit/change handling (and any helper that builds the request payload) so that
when canEditLegacyAdvancedFields is false, those legacy fields are stripped
before passing data to TvRequestModal, while preserving language-only values.


const { data: serverData, isValidating } =
useSWR<ServiceCommonServerWithDetails>(
selectedServer !== null
Expand Down Expand Up @@ -353,141 +362,155 @@ const AdvancedRequester = ({
<div className="rounded-md">
{!!data && selectedServer !== null && (
<div className="flex flex-col md:flex-row">
{data.filter((server) => server.is4k === is4k).length > 1 && (
<div className="mb-3 w-full flex-shrink-0 flex-grow last:pr-0 md:w-1/4 md:pr-4">
<label htmlFor="server">
{intl.formatMessage(messages.destinationserver)}
</label>
<select
id="server"
name="server"
value={selectedServer}
onChange={(e) => setSelectedServer(Number(e.target.value))}
onBlur={(e) => setSelectedServer(Number(e.target.value))}
className="border-gray-700 bg-gray-800"
>
{data
.filter((server) => server.is4k === is4k)
.map((server) => (
<option
key={`server-list-${server.id}`}
value={server.id}
>
{server.isDefault
? intl.formatMessage(messages.default, {
name: server.name,
})
: server.name}
</option>
))}
</select>
</div>
)}
{(isValidating ||
!serverData ||
serverData.profiles.length > 1) && (
<div className="mb-3 w-full flex-shrink-0 flex-grow last:pr-0 md:w-1/4 md:pr-4">
<label htmlFor="profile">
{intl.formatMessage(messages.qualityprofile)}
</label>
<select
id="profile"
name="profile"
value={selectedProfile}
onChange={(e) => setSelectedProfile(Number(e.target.value))}
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
className="border-gray-700 bg-gray-800"
disabled={isValidating || !serverData}
>
{(isValidating || !serverData) && (
<option value="">
{intl.formatMessage(globalMessages.loading)}
</option>
)}
{!isValidating &&
serverData &&
serverData.profiles
.toSorted((a, b) =>
a.name.localeCompare(b.name, intl.locale, {
numeric: true,
sensitivity: 'base',
})
)
.map((profile) => (
{canEditLegacyAdvancedFields &&
data.filter((server) => server.is4k === is4k).length > 1 && (
<div className="mb-3 w-full flex-shrink-0 flex-grow last:pr-0 md:w-1/4 md:pr-4">
<label htmlFor="server">
{intl.formatMessage(messages.destinationserver)}
</label>
<select
id="server"
name="server"
value={selectedServer}
onChange={(e) => setSelectedServer(Number(e.target.value))}
onBlur={(e) => setSelectedServer(Number(e.target.value))}
className="border-gray-700 bg-gray-800"
>
{data
.filter((server) => server.is4k === is4k)
.map((server) => (
<option
key={`profile-list${profile.id}`}
value={profile.id}
key={`server-list-${server.id}`}
value={server.id}
>
{isAnime &&
serverData.server.activeAnimeProfileId === profile.id
{server.isDefault
? intl.formatMessage(messages.default, {
name: profile.name,
name: server.name,
})
: !isAnime &&
serverData.server.activeProfileId === profile.id
: server.name}
</option>
))}
</select>
</div>
)}
{canEditLegacyAdvancedFields &&
(isValidating ||
!serverData ||
serverData.profiles.length > 1) && (
<div className="mb-3 w-full flex-shrink-0 flex-grow last:pr-0 md:w-1/4 md:pr-4">
<label htmlFor="profile">
{intl.formatMessage(messages.qualityprofile)}
</label>
<select
id="profile"
name="profile"
value={selectedProfile}
onChange={(e) => setSelectedProfile(Number(e.target.value))}
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
className="border-gray-700 bg-gray-800"
disabled={isValidating || !serverData}
>
{(isValidating || !serverData) && (
<option value="">
{intl.formatMessage(globalMessages.loading)}
</option>
)}
{!isValidating &&
serverData &&
serverData.profiles
.toSorted((a, b) =>
a.name.localeCompare(b.name, intl.locale, {
numeric: true,
sensitivity: 'base',
})
)
.map((profile) => (
<option
key={`profile-list${profile.id}`}
value={profile.id}
>
{isAnime &&
serverData.server.activeAnimeProfileId ===
profile.id
? intl.formatMessage(messages.default, {
name: profile.name,
})
: profile.name}
</option>
))}
</select>
</div>
)}
{(isValidating ||
!serverData ||
serverData.rootFolders.length > 1) && (
<div className="mb-3 w-full flex-shrink-0 flex-grow last:pr-0 md:w-1/4 md:pr-4">
<label htmlFor="folder">
{intl.formatMessage(messages.rootfolder)}
</label>
<select
id="folder"
name="folder"
value={selectedFolder}
onChange={(e) => setSelectedFolder(e.target.value)}
onBlur={(e) => setSelectedFolder(e.target.value)}
className="border-gray-700 bg-gray-800"
disabled={isValidating || !serverData}
>
{(isValidating || !serverData) && (
<option value="">
{intl.formatMessage(globalMessages.loading)}
</option>
)}
{!isValidating &&
serverData &&
serverData.rootFolders.map((folder) => (
<option
key={`folder-list${folder.id}`}
value={folder.path}
>
{isAnime &&
serverData.server.activeAnimeDirectory === folder.path
? intl.formatMessage(messages.default, {
name: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
}),
})
: !isAnime &&
serverData.server.activeDirectory === folder.path
: !isAnime &&
serverData.server.activeProfileId ===
profile.id
? intl.formatMessage(messages.default, {
name: profile.name,
})
: profile.name}
</option>
))}
</select>
</div>
)}
{canEditLegacyAdvancedFields &&
(isValidating ||
!serverData ||
serverData.rootFolders.length > 1) && (
<div className="mb-3 w-full flex-shrink-0 flex-grow last:pr-0 md:w-1/4 md:pr-4">
<label htmlFor="folder">
{intl.formatMessage(messages.rootfolder)}
</label>
<select
id="folder"
name="folder"
value={selectedFolder}
onChange={(e) => setSelectedFolder(e.target.value)}
onBlur={(e) => setSelectedFolder(e.target.value)}
className="border-gray-700 bg-gray-800"
disabled={isValidating || !serverData}
>
{(isValidating || !serverData) && (
<option value="">
{intl.formatMessage(globalMessages.loading)}
</option>
)}
{!isValidating &&
serverData &&
serverData.rootFolders.map((folder) => (
<option
key={`folder-list${folder.id}`}
value={folder.path}
>
{isAnime &&
serverData.server.activeAnimeDirectory === folder.path
? intl.formatMessage(messages.default, {
name: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
}),
})
: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
})}
</option>
))}
</select>
</div>
)}
: !isAnime &&
serverData.server.activeDirectory ===
folder.path
? intl.formatMessage(messages.default, {
name: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
}),
})
: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
})}
</option>
))}
</select>
</div>
)}
{type === 'tv' &&
currentHasPermission(
[
Permission.MANAGE_REQUESTS,
Permission.REQUEST_ADVANCED,
Permission.REQUEST_ADVANCED_LANGUAGE,
],
{ type: 'or' }
) &&
Comment on lines +365 to +513

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the “Advanced” section visibility permission-aware.

The earlier null-return guard still counts profile/root/server/tag options even when canEditLegacyAdvancedFields hides them. A language-only user with no language choices but multiple hidden legacy choices can get an empty Advanced section. Base the section guard on visible options only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/RequestModal/AdvancedRequester/index.tsx` around lines 365 -
513, The Advanced section guard in AdvancedRequester is still using legacy
counts even when canEditLegacyAdvancedFields hides those fields, so compute
visibility from only the options the user can actually see. Update the
section-level condition around the server/profile/root folder/tag controls to
derive its “has content” check from the same permission-aware flags used in the
rendered selects, ensuring a language-only user with no language options does
not get an empty Advanced section. Focus on the visibility logic near the
existing canEditLegacyAdvancedFields checks and the currentHasPermission gate.

(isValidating ||
!serverData ||
(serverData.languageProfiles ?? []).length > 1) && (
Expand Down Expand Up @@ -540,7 +563,8 @@ const AdvancedRequester = ({
)}
</div>
)}
{selectedServer !== null &&
{canEditLegacyAdvancedFields &&
selectedServer !== null &&
(isValidating || !serverData || !!serverData?.tags?.length) && (
<div className="mb-2">
<label htmlFor="tags">{intl.formatMessage(messages.tags)}</label>
Expand Down
10 changes: 8 additions & 2 deletions src/components/RequestModal/TvRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -714,8 +714,14 @@ const TvRequestModal = ({
</div>
</div>
</div>
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
{hasPermission(
[
Permission.MANAGE_REQUESTS,
Permission.REQUEST_ADVANCED,
Permission.REQUEST_ADVANCED_LANGUAGE,
],
{ type: 'or' }
) && (
<AdvancedRequester
type="tv"
is4k={is4k}
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@
"components.PermissionEdit.adminDescription": "Full administrator access. Bypasses all other permission checks.",
"components.PermissionEdit.advancedrequest": "Advanced Requests",
"components.PermissionEdit.advancedrequestDescription": "Grant permission to modify advanced media request options.",
"components.PermissionEdit.advancedrequestLanguage": "Advanced Requests: Language Profile",
"components.PermissionEdit.advancedrequestLanguageDescription": "Grant permission to override only the language profile on series requests.",
"components.PermissionEdit.autoapprove": "Auto-Approve",
"components.PermissionEdit.autoapprove4k": "Auto-Approve 4K",
"components.PermissionEdit.autoapprove4kDescription": "Grant automatic approval for all 4K media requests.",
Expand Down