Skip to content

feat(permissions): add granular Advanced Request permission for language profile#3201

Open
Wr0ngName wants to merge 1 commit into
seerr-team:developfrom
Wr0ngName:feat/granular-advanced-language-profile
Open

feat(permissions): add granular Advanced Request permission for language profile#3201
Wr0ngName wants to merge 1 commit into
seerr-team:developfrom
Wr0ngName:feat/granular-advanced-language-profile

Conversation

@Wr0ngName

@Wr0ngName Wr0ngName commented Jun 25, 2026

Copy link
Copy Markdown

Description

Splits the all-or-nothing REQUEST_ADVANCED permission so administrators can grant a user the ability to override only the Sonarr language profile on series requests, without simultaneously unlocking quality profile, root folder, tags, or destination server.

The driving use case is a multi-user / family setup where one user needs to fetch foreign-language audio (e.g. French dubs for a child) on TV requests while keeping defaults for everything else.

This PR is a deliberately small first slice and lays a pattern the remaining four sub-permissions can follow in subsequent PRs to fully close the issue.

How it works:

  • New REQUEST_ADVANCED_LANGUAGE permission bit added to the existing bitmask enum (uses the previously-unused bit 536870912, safely within JS's 32-bit signed range).
  • In PermissionEdit, the new bit is nested as a child of REQUEST_ADVANCED, so toggling the parent grants the child via the existing parent-grants-children rule in PermissionOption.
  • Inside AdvancedRequester, every field's render condition gains an OR-check against the new bit, the legacy REQUEST_ADVANCED, and MANAGE_REQUESTS. Legacy users with REQUEST_ADVANCED keep the exact same experience; a user holding only the new granular bit sees just the Language Profile dropdown.
  • The outer gate in TvRequestModal is widened to admit the granular-only user.

No DB migration, no API contract change. The other four Advanced fields (server / quality / folder / tags) are now gated by the legacy REQUEST_ADVANCED inside AdvancedRequester, so widening the outer gate does not leak those fields to a granular-only user.

Out of scope (will be tracked separately): server-side enforcement on the request POST/PUT endpoints. Today, REQUEST_ADVANCED is enforced UI-side only on create — a crafted POST already bypasses the gate. That predates this change.

How Has This Been Tested?

Tested manually against a local dev instance (pnpm dev via the project's Dockerfile.local) connected to a Sonarr server with multiple language profiles, multiple quality profiles, multiple root folders, and several tags.

Scenarios exercised:

  • Legacy user (only REQUEST_ADVANCED bit set): Opened a TV request modal. All five Advanced fields render exactly as on develop (verified side-by-side). Submitted a request with non-default language; Sonarr received the correct languageProfileId.
  • Granular-only user (REQUEST + REQUEST_TV + REQUEST_ADVANCED_LANGUAGE): Opened a TV request modal. Advanced section appears with only the Language Profile dropdown — quality, folder, tags, server are all hidden. Submitted a request; Sonarr received the override.
  • No-Advanced user (REQUEST + REQUEST_TV only): No Advanced section rendered.
  • User-edit UI: As admin, toggled the REQUEST_ADVANCED parent checkbox; the new REQUEST_ADVANCED_LANGUAGE child becomes checked and disabled (existing parent-grants-children semantics). Toggled the child alone with the parent unchecked; works independently.
  • Movie regression: Granular-only user opened a movie request modal; no Advanced section appears (language profile is TV-only — no leakage into the movie path).

CI checks (pnpm lint, pnpm format:check, pnpm build, pnpm i18n:extract) run locally in the project's pinned Node 22 alpine container before pushing.

Screenshots / Logs (if applicable)

N/A — UI changes are conditional renders only; no visual change for legacy users. Will add screenshots on request.

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (if required)

AI Disclosure: AI-assisted code generation, fully human-reviewed.

Summary by CodeRabbit

  • New Features

    • Added a new advanced request permission for language profile access.
    • Advanced TV request options now support users with the new language-profile permission.
  • Bug Fixes

    • Refined advanced request field visibility so only users with the appropriate access can see legacy server, profile, root folder, and tag selectors.
  • Documentation

    • Added new interface text for the language-profile permission option.

…age profile

Introduces a new `REQUEST_ADVANCED_LANGUAGE` permission bit so admins can
grant access to override only the Sonarr language profile on series
requests, without also unlocking quality profile, root folder, tags, or
destination server changes.

Behavior:
- Existing users with `REQUEST_ADVANCED` or `MANAGE_REQUESTS` continue to
  see and edit every Advanced field unchanged (OR-checks on every gate).
- A user holding only `REQUEST_ADVANCED_LANGUAGE` (plus a base request
  permission) sees the Advanced section on TV requests with only the
  Language Profile dropdown.
- `PermissionEdit` nests the new permission as a child of
  `REQUEST_ADVANCED`, so toggling the parent in the user-edit UI grants
  the granular bit via the existing parent-grants-children rule.

Partially addresses seerr-team#2248. Follow-up PRs will add the remaining four
sub-permissions (server, quality, folder, tags) using the same pattern,
and harden the request POST/PUT endpoints which today rely on UI-only
gating for Advanced fields.
@Wr0ngName Wr0ngName requested a review from a team as a code owner June 25, 2026 17:24
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds REQUEST_ADVANCED_LANGUAGE, exposes it in permission editing and locale strings, and updates request modal visibility and field gating so the advanced request UI recognizes the new permission alongside existing advanced request permissions.

Changes

Advanced Request Language Permission

Layer / File(s) Summary
Permission catalog and labels
server/lib/permissions.ts, src/components/PermissionEdit/index.tsx, src/i18n/locale/en.json
Adds REQUEST_ADVANCED_LANGUAGE, adds the new advanced-request child option in the permission editor, and inserts the English label and description strings.
Advanced request field gating
src/components/RequestModal/AdvancedRequester/index.tsx
Introduces canEditLegacyAdvancedFields and applies it to the server, profile, root folder, and tags selectors.
TV advanced requester access
src/components/RequestModal/TvRequestModal.tsx
Allows the advanced requester UI when the user has MANAGE_REQUESTS, REQUEST_ADVANCED, or REQUEST_ADVANCED_LANGUAGE.

Sequence Diagram(s)

sequenceDiagram
  participant TvRequestModal
  participant hasPermission
  participant AdvancedRequester

  TvRequestModal->>hasPermission: check MANAGE_REQUESTS, REQUEST_ADVANCED, REQUEST_ADVANCED_LANGUAGE
  hasPermission-->>TvRequestModal: permission result
  TvRequestModal->>AdvancedRequester: render when allowed
  AdvancedRequester->>hasPermission: compute canEditLegacyAdvancedFields
  hasPermission-->>AdvancedRequester: legacy-compatible result
  AdvancedRequester-->>TvRequestModal: show server, profile, root folder, and tags selectors
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

twitch I hopped through permissions bright and new,
A language path now peeks through! 🐰
Tags and profiles obey the gate,
Advanced requests know their state,
Thump-thump, the settings bloom in view.

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR adds a language-profile permission, but linked issue #2248 asks for granular Tags, Path, and Quality Profile controls instead. Implement the requested per-field advanced request permissions for Tags, Path, and Quality Profile, or update the linked issue if the scope changed.
Out of Scope Changes check ⚠️ Warning The language-profile permission, UI, and TV modal changes are outside the linked issue's scope, which is limited to Tags, Path, and Quality Profile. Remove the language-profile split from this PR or add the corresponding issue scope and acceptance criteria.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main change to add a granular advanced request permission for language profile.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/components/RequestModal/AdvancedRequester/index.tsx`:
- Around line 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e3af3b7b-38bc-4432-960b-45fa7946d73d

📥 Commits

Reviewing files that changed from the base of the PR and between 8ad191f and a36631f.

📒 Files selected for processing (5)
  • server/lib/permissions.ts
  • src/components/PermissionEdit/index.tsx
  • src/components/RequestModal/AdvancedRequester/index.tsx
  • src/components/RequestModal/TvRequestModal.tsx
  • src/i18n/locale/en.json

Comment on lines +118 to +121
const canEditLegacyAdvancedFields = currentHasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_ADVANCED],
{ type: 'or' }
);

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.

Comment on lines +365 to +513
{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' }
) &&

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Advanced Requests - Grant/Deny for Tags, Path, Quality Profile

1 participant