mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
5 Commits
preview-ne
...
preview-li
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75afa742ea | ||
|
|
525a538f34 | ||
|
|
0d2273ff6e | ||
|
|
e035cd84ae | ||
|
|
438ccfe9c3 |
@@ -7,7 +7,7 @@
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
"contributorsPerLine": 7,
|
||||
"projectName": "jellyseerr",
|
||||
"projectOwner": "Fallenbagel",
|
||||
"projectOwner": "fallenbagel",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true,
|
||||
@@ -601,6 +601,15 @@
|
||||
"contributions": [
|
||||
"design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ishanjain28",
|
||||
"name": "Ishan Jain",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7921368?v=4",
|
||||
"profile": "https://ishanjain.me",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
101
README.md
101
README.md
@@ -11,7 +11,7 @@
|
||||
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-65-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-66-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||
@@ -86,89 +86,90 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/Fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/undone37"><img src="https://avatars.githubusercontent.com/u/10513808?v=4?s=100" width="100px;" alt="undone37"/><br /><sub><b>undone37</b></sub></a><br /><a href="#translation-undone37" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CheChu10"><img src="https://avatars.githubusercontent.com/u/32913133?v=4?s=100" width="100px;" alt="Chechu García"/><br /><sub><b>Chechu García</b></sub></a><br /><a href="#translation-CheChu10" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DimitriDR"><img src="https://avatars.githubusercontent.com/u/56969769?v=4?s=100" width="100px;" alt="Dimitri"/><br /><sub><b>Dimitri</b></sub></a><br /><a href="#translation-DimitriDR" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://geoffrey-coulaud.fr"><img src="https://avatars.githubusercontent.com/u/20744730?v=4?s=100" width="100px;" alt="Geoffrey Coulaud"/><br /><sub><b>Geoffrey Coulaud</b></sub></a><br /><a href="#translation-GeoffreyCoulaud" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/d-fendrich"><img src="https://avatars.githubusercontent.com/u/27904138?v=4?s=100" width="100px;" alt="d-fendrich"/><br /><sub><b>d-fendrich</b></sub></a><br /><a href="#translation-d-fendrich" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GkhnGRBZ"><img src="https://avatars.githubusercontent.com/u/127258824?v=4?s=100" width="100px;" alt="GkhnGRBZ"/><br /><sub><b>GkhnGRBZ</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=GkhnGRBZ" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://benhaney.com"><img src="https://avatars.githubusercontent.com/u/31331498?v=4?s=100" width="100px;" alt="Ben Haney"/><br /><sub><b>Ben Haney</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=benhaney" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Wunderharke"><img src="https://avatars.githubusercontent.com/u/5105672?v=4?s=100" width="100px;" alt="Wunderharke"/><br /><sub><b>Wunderharke</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Wunderharke" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GkhnGRBZ"><img src="https://avatars.githubusercontent.com/u/127258824?v=4?s=100" width="100px;" alt="GkhnGRBZ"/><br /><sub><b>GkhnGRBZ</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=GkhnGRBZ" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://benhaney.com"><img src="https://avatars.githubusercontent.com/u/31331498?v=4?s=100" width="100px;" alt="Ben Haney"/><br /><sub><b>Ben Haney</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benhaney" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Wunderharke"><img src="https://avatars.githubusercontent.com/u/5105672?v=4?s=100" width="100px;" alt="Wunderharke"/><br /><sub><b>Wunderharke</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Wunderharke" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/methbkts"><img src="https://avatars.githubusercontent.com/u/30674934?v=4?s=100" width="100px;" alt="Metin Bektas"/><br /><sub><b>Metin Bektas</b></sub></a><br /><a href="#infra-methbkts" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrewkolda"><img src="https://avatars.githubusercontent.com/u/158614532?v=4?s=100" width="100px;" alt="andrewkolda"/><br /><sub><b>andrewkolda</b></sub></a><br /><a href="#design-andrewkolda" title="Design">🎨</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://ishanjain.me"><img src="https://avatars.githubusercontent.com/u/7921368?v=4?s=100" width="100px;" alt="Ishan Jain"/><br /><sub><b>Ishan Jain</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ishanjain28" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -13,10 +13,10 @@ describe('General Settings', () => {
|
||||
});
|
||||
|
||||
it('modifies setting that requires restart', () => {
|
||||
cy.visit('/settings');
|
||||
cy.visit('/settings/network');
|
||||
|
||||
cy.get('#trustProxy').click();
|
||||
cy.get('[data-testid=settings-main-form]').submit();
|
||||
cy.get('[data-testid=settings-network-form]').submit();
|
||||
cy.get('[data-testid=modal-title]').should(
|
||||
'contain',
|
||||
'Server Restart Required'
|
||||
@@ -26,7 +26,7 @@ describe('General Settings', () => {
|
||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||
|
||||
cy.get('[type=checkbox]#trustProxy').click();
|
||||
cy.get('[data-testid=settings-main-form]').submit();
|
||||
cy.get('[data-testid=settings-network-form]').submit();
|
||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ Cypress.Commands.add('login', (email, password) => {
|
||||
[email, password],
|
||||
() => {
|
||||
cy.visit('/login');
|
||||
cy.contains('Use your Overseerr account').click();
|
||||
|
||||
cy.get('[data-testid=email]').type(email);
|
||||
cy.get('[data-testid=password]').type(password);
|
||||
|
||||
@@ -14,14 +14,6 @@ When disabled, your mediaserver OAuth becomes the only sign-in option, and any "
|
||||
|
||||
This setting is **enabled** by default.
|
||||
|
||||
## Enable Jellyfin/Emby/Plex Sign-In
|
||||
|
||||
When enabled, users will be able to sign in to Jellyseerr using their Jellyfin/Emby/Plex credentials, provided they have linked their media server accounts.
|
||||
|
||||
When disabled, users will only be able to sign in using their email address. Users without a password set will not be able to sign in to Jellyseerr.
|
||||
|
||||
This setting is **enabled** by default.
|
||||
|
||||
## Enable New Jellyfin/Emby/Plex Sign-In
|
||||
|
||||
When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.
|
||||
|
||||
@@ -164,12 +164,6 @@ components:
|
||||
applicationUrl:
|
||||
type: string
|
||||
example: https://os.example.com
|
||||
trustProxy:
|
||||
type: boolean
|
||||
example: true
|
||||
csrfProtection:
|
||||
type: boolean
|
||||
example: false
|
||||
hideAvailable:
|
||||
type: boolean
|
||||
example: false
|
||||
@@ -191,12 +185,21 @@ components:
|
||||
enableSpecialEpisodes:
|
||||
type: boolean
|
||||
example: false
|
||||
NetworkSettings:
|
||||
type: object
|
||||
properties:
|
||||
csrfProtection:
|
||||
type: boolean
|
||||
example: false
|
||||
forceIpv4First:
|
||||
type: boolean
|
||||
example: false
|
||||
dnsServers:
|
||||
type: string
|
||||
example: '1.1.1.1'
|
||||
trustProxy:
|
||||
type: boolean
|
||||
example: true
|
||||
PlexLibrary:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2045,6 +2048,37 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MainSettings'
|
||||
/settings/network:
|
||||
get:
|
||||
summary: Get network settings
|
||||
description: Retrieves all network settings in a JSON object.
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MainSettings'
|
||||
post:
|
||||
summary: Update network settings
|
||||
description: Updates network settings with the provided values.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NetworkSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were sucessfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NetworkSettings'
|
||||
/settings/main/regenerate:
|
||||
post:
|
||||
summary: Get main settings with newly-generated API key
|
||||
|
||||
@@ -86,7 +86,6 @@
|
||||
"react-spring": "9.7.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
"react-use-clipboard": "1.0.9",
|
||||
"reflect-metadata": "0.1.13",
|
||||
@@ -96,7 +95,6 @@
|
||||
"sqlite3": "5.1.4",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.2.5",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typeorm": "0.3.11",
|
||||
"undici": "^6.20.1",
|
||||
"web-push": "3.5.0",
|
||||
|
||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -170,9 +170,6 @@ importers:
|
||||
react-toast-notifications:
|
||||
specifier: 2.5.1
|
||||
version: 2.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-transition-group:
|
||||
specifier: ^4.4.5
|
||||
version: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-truncate-markup:
|
||||
specifier: 5.1.2
|
||||
version: 5.1.2(react@18.3.1)
|
||||
@@ -200,9 +197,6 @@ importers:
|
||||
swr:
|
||||
specifier: 2.2.5
|
||||
version: 2.2.5(react@18.3.1)
|
||||
tailwind-merge:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
typeorm:
|
||||
specifier: 0.3.11
|
||||
version: 0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
||||
@@ -8850,9 +8844,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
tailwindcss@3.2.7:
|
||||
resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==}
|
||||
engines: {node: '>=12.13.0'}
|
||||
@@ -11302,7 +11293,7 @@ snapshots:
|
||||
'@emotion/babel-plugin@11.11.0':
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.24.7
|
||||
'@babel/runtime': 7.26.0
|
||||
'@babel/runtime': 7.24.7
|
||||
'@emotion/hash': 0.9.1
|
||||
'@emotion/memoize': 0.8.1
|
||||
'@emotion/serialize': 1.1.4
|
||||
@@ -11332,7 +11323,7 @@ snapshots:
|
||||
|
||||
'@emotion/core@10.3.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.0
|
||||
'@babel/runtime': 7.24.7
|
||||
'@emotion/cache': 10.0.29
|
||||
'@emotion/css': 10.0.27
|
||||
'@emotion/serialize': 0.11.16
|
||||
@@ -11360,7 +11351,7 @@ snapshots:
|
||||
|
||||
'@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.0
|
||||
'@babel/runtime': 7.24.7
|
||||
'@emotion/babel-plugin': 11.11.0
|
||||
'@emotion/cache': 11.11.0
|
||||
'@emotion/serialize': 1.1.4
|
||||
@@ -14263,13 +14254,13 @@ snapshots:
|
||||
|
||||
babel-plugin-macros@2.8.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.0
|
||||
'@babel/runtime': 7.24.7
|
||||
cosmiconfig: 6.0.0
|
||||
resolve: 1.22.8
|
||||
|
||||
babel-plugin-macros@3.1.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.0
|
||||
'@babel/runtime': 7.24.7
|
||||
cosmiconfig: 7.1.0
|
||||
resolve: 1.22.8
|
||||
|
||||
@@ -15359,7 +15350,7 @@ snapshots:
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.0
|
||||
'@babel/runtime': 7.24.7
|
||||
csstype: 3.1.3
|
||||
|
||||
dom-serializer@1.4.1:
|
||||
@@ -19375,7 +19366,7 @@ snapshots:
|
||||
|
||||
react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.0
|
||||
'@babel/runtime': 7.24.7
|
||||
dom-helpers: 5.2.1
|
||||
loose-envify: 1.4.0
|
||||
prop-types: 15.8.1
|
||||
@@ -19502,7 +19493,7 @@ snapshots:
|
||||
|
||||
regenerator-transform@0.15.2:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.0
|
||||
'@babel/runtime': 7.24.7
|
||||
|
||||
regexp.prototype.flags@1.5.2:
|
||||
dependencies:
|
||||
@@ -20283,8 +20274,6 @@ snapshots:
|
||||
react: 18.3.1
|
||||
use-sync-external-store: 1.2.2(react@18.3.1)
|
||||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwindcss@3.2.7(postcss@8.4.21)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)):
|
||||
dependencies:
|
||||
arg: 5.0.2
|
||||
|
||||
@@ -734,8 +734,11 @@ export class MediaRequest {
|
||||
media.mediaType === MediaType.MOVIE &&
|
||||
this.status === MediaRequestStatus.DECLINED
|
||||
) {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
const statusField = this.is4k ? 'status4k' : 'status';
|
||||
await mediaRepository.update(
|
||||
{ id: this.media.id },
|
||||
{ [statusField]: MediaStatus.UNKNOWN }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -752,8 +755,11 @@ export class MediaRequest {
|
||||
).length === 0 &&
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
||||
) {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
const statusField = this.is4k ? 'status4k' : 'status';
|
||||
mediaRepository.update(
|
||||
{ id: this.media.id },
|
||||
{ [statusField]: MediaStatus.UNKNOWN }
|
||||
);
|
||||
}
|
||||
|
||||
// Approve child seasons if parent is approved
|
||||
@@ -955,8 +961,10 @@ export class MediaRequest {
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
this.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(this);
|
||||
|
||||
await requestRepository.update(this.id, {
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -986,18 +994,22 @@ export class MediaRequest {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
radarrMovie.id;
|
||||
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
radarrMovie.titleSlug;
|
||||
media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id;
|
||||
await mediaRepository.save(media);
|
||||
const updateFields = {
|
||||
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
||||
radarrMovie.id,
|
||||
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
||||
radarrMovie.titleSlug,
|
||||
[this.is4k ? 'serviceId4k' : 'serviceId']: radarrMovie?.id,
|
||||
};
|
||||
|
||||
await mediaRepository.update({ id: this.media.id }, updateFields);
|
||||
})
|
||||
.catch(async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
this.status = MediaRequestStatus.FAILED;
|
||||
await requestRepository.save(this);
|
||||
await requestRepository.update(this.id, {
|
||||
status: MediaRequestStatus.FAILED,
|
||||
});
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||
@@ -1113,8 +1125,9 @@ export class MediaRequest {
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
this.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(this);
|
||||
await requestRepository.update(this.id, {
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,23 +72,26 @@ app
|
||||
|
||||
// Load Settings
|
||||
const settings = await getSettings().load();
|
||||
restartFlag.initializeSettings(settings.main);
|
||||
restartFlag.initializeSettings(settings);
|
||||
|
||||
// Check if we force IPv4 first
|
||||
if (process.env.forceIpv4First === 'true' || settings.main.forceIpv4First) {
|
||||
if (
|
||||
process.env.forceIpv4First === 'true' ||
|
||||
settings.network.forceIpv4First
|
||||
) {
|
||||
dns.setDefaultResultOrder('ipv4first');
|
||||
net.setDefaultAutoSelectFamily(false);
|
||||
}
|
||||
|
||||
if (settings.main.dnsServers.trim() !== '') {
|
||||
if (settings.network.dnsServers.trim() !== '') {
|
||||
dns.setServers(
|
||||
settings.main.dnsServers.split(',').map((server) => server.trim())
|
||||
settings.network.dnsServers.split(',').map((server) => server.trim())
|
||||
);
|
||||
}
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.main.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.main.proxy);
|
||||
if (settings.network.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.network.proxy);
|
||||
}
|
||||
|
||||
// Migrate library types
|
||||
@@ -143,7 +146,7 @@ app
|
||||
await DiscoverSlider.bootstrapSliders();
|
||||
|
||||
const server = express();
|
||||
if (settings.main.trustProxy) {
|
||||
if (settings.network.trustProxy) {
|
||||
server.enable('trust proxy');
|
||||
}
|
||||
server.use(cookieParser());
|
||||
@@ -164,7 +167,7 @@ app
|
||||
next();
|
||||
}
|
||||
});
|
||||
if (settings.main.csrfProtection) {
|
||||
if (settings.network.csrfProtection) {
|
||||
server.use(
|
||||
csurf({
|
||||
cookie: {
|
||||
@@ -194,7 +197,7 @@ app
|
||||
cookie: {
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||
httpOnly: true,
|
||||
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
|
||||
sameSite: settings.network.csrfProtection ? 'strict' : 'lax',
|
||||
secure: 'auto',
|
||||
},
|
||||
store: new TypeormStore({
|
||||
|
||||
@@ -30,7 +30,6 @@ export interface PublicSettingsResponse {
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
discoverRegion: string;
|
||||
|
||||
@@ -115,7 +115,6 @@ export interface MainSettings {
|
||||
apiKey: string;
|
||||
applicationTitle: string;
|
||||
applicationUrl: string;
|
||||
csrfProtection: boolean;
|
||||
cacheImages: boolean;
|
||||
defaultPermissions: number;
|
||||
defaultQuotas: {
|
||||
@@ -124,18 +123,21 @@ export interface MainSettings {
|
||||
};
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
newPlexLogin: boolean;
|
||||
discoverRegion: string;
|
||||
streamingRegion: string;
|
||||
originalLanguage: string;
|
||||
trustProxy: boolean;
|
||||
mediaServerType: number;
|
||||
partialRequestsEnabled: boolean;
|
||||
enableSpecialEpisodes: boolean;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface NetworkSettings {
|
||||
csrfProtection: boolean;
|
||||
forceIpv4First: boolean;
|
||||
dnsServers: string;
|
||||
locale: string;
|
||||
trustProxy: boolean;
|
||||
proxy: ProxySettings;
|
||||
}
|
||||
|
||||
@@ -148,7 +150,6 @@ interface FullPublicSettings extends PublicSettings {
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
discoverRegion: string;
|
||||
@@ -315,6 +316,7 @@ export interface AllSettings {
|
||||
public: PublicSettings;
|
||||
notifications: NotificationSettings;
|
||||
jobs: Record<JobId, JobSettings>;
|
||||
network: NetworkSettings;
|
||||
}
|
||||
|
||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||
@@ -333,7 +335,6 @@ class Settings {
|
||||
apiKey: '',
|
||||
applicationTitle: 'Jellyseerr',
|
||||
applicationUrl: '',
|
||||
csrfProtection: false,
|
||||
cacheImages: false,
|
||||
defaultPermissions: Permission.REQUEST,
|
||||
defaultQuotas: {
|
||||
@@ -342,28 +343,14 @@ class Settings {
|
||||
},
|
||||
hideAvailable: false,
|
||||
localLogin: true,
|
||||
mediaServerLogin: true,
|
||||
newPlexLogin: true,
|
||||
discoverRegion: '',
|
||||
streamingRegion: '',
|
||||
originalLanguage: '',
|
||||
trustProxy: false,
|
||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||
partialRequestsEnabled: true,
|
||||
enableSpecialEpisodes: false,
|
||||
forceIpv4First: false,
|
||||
dnsServers: '',
|
||||
locale: 'en',
|
||||
proxy: {
|
||||
enabled: false,
|
||||
hostname: '',
|
||||
port: 8080,
|
||||
useSsl: false,
|
||||
user: '',
|
||||
password: '',
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
},
|
||||
plex: {
|
||||
name: '',
|
||||
@@ -516,6 +503,22 @@ class Settings {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
},
|
||||
network: {
|
||||
csrfProtection: false,
|
||||
trustProxy: false,
|
||||
forceIpv4First: false,
|
||||
dnsServers: '',
|
||||
proxy: {
|
||||
enabled: false,
|
||||
hostname: '',
|
||||
port: 8080,
|
||||
useSsl: false,
|
||||
user: '',
|
||||
password: '',
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (initialSettings) {
|
||||
this.data = merge(this.data, initialSettings);
|
||||
@@ -585,8 +588,6 @@ class Settings {
|
||||
applicationUrl: this.data.main.applicationUrl,
|
||||
hideAvailable: this.data.main.hideAvailable,
|
||||
localLogin: this.data.main.localLogin,
|
||||
mediaServerLogin: this.data.main.mediaServerLogin,
|
||||
jellyfinExternalHost: this.data.jellyfin.externalHostname,
|
||||
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
|
||||
movie4kEnabled: this.data.radarr.some(
|
||||
(radarr) => radarr.is4k && radarr.isDefault
|
||||
@@ -627,6 +628,14 @@ class Settings {
|
||||
this.data.jobs = data;
|
||||
}
|
||||
|
||||
get network(): NetworkSettings {
|
||||
return this.data.network;
|
||||
}
|
||||
|
||||
set network(data: NetworkSettings) {
|
||||
this.data.network = data;
|
||||
}
|
||||
|
||||
get clientId(): string {
|
||||
return this.data.clientId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { AllSettings } from '@server/lib/settings';
|
||||
|
||||
const migrateNetworkSettings = (settings: any): AllSettings => {
|
||||
if (settings.network) {
|
||||
return settings;
|
||||
}
|
||||
const newSettings = { ...settings };
|
||||
newSettings.network = {
|
||||
...settings.network,
|
||||
csrfProtection: settings.main.csrfProtection ?? false,
|
||||
trustProxy: settings.main.trustProxy ?? false,
|
||||
forceIpv4First: settings.main.forceIpv4First ?? false,
|
||||
dnsServers: settings.main.dnsServers ?? '',
|
||||
proxy: settings.main.proxy ?? {
|
||||
enabled: false,
|
||||
hostname: '',
|
||||
port: 8080,
|
||||
useSsl: false,
|
||||
user: '',
|
||||
password: '',
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
};
|
||||
delete settings.main.csrfProtection;
|
||||
delete settings.main.trustProxy;
|
||||
delete settings.main.forceIpv4First;
|
||||
delete settings.main.dnsServers;
|
||||
delete settings.main.proxy;
|
||||
return newSettings;
|
||||
};
|
||||
|
||||
export default migrateNetworkSettings;
|
||||
@@ -56,9 +56,8 @@ authRoutes.post('/plex', async (req, res, next) => {
|
||||
}
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||
(settings.main.mediaServerLogin === false ||
|
||||
settings.main.mediaServerType != MediaServerType.PLEX)
|
||||
settings.main.mediaServerType != MediaServerType.PLEX &&
|
||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
||||
) {
|
||||
return res.status(500).json({ error: 'Plex login is disabled' });
|
||||
}
|
||||
@@ -232,13 +231,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
|
||||
//Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
|
||||
if (
|
||||
// media server not configured, allow login for setup
|
||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||
(settings.main.mediaServerLogin === false ||
|
||||
// media server is neither jellyfin or emby
|
||||
(settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
||||
settings.jellyfin.ip !== ''))
|
||||
settings.jellyfin.ip !== ''
|
||||
) {
|
||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||
}
|
||||
|
||||
@@ -78,6 +78,21 @@ settingsRoutes.post('/main', async (req, res) => {
|
||||
return res.status(200).json(settings.main);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/network', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.network);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/network', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.network = merge(settings.network, req.body);
|
||||
await settings.save();
|
||||
|
||||
return res.status(200).json(settings.network);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/main/regenerate', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
export default async function createCustomProxyAgent(
|
||||
proxySettings: ProxySettings
|
||||
) {
|
||||
const defaultAgent = new Agent();
|
||||
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
|
||||
|
||||
const skipUrl = (url: string) => {
|
||||
const hostname = new URL(url).hostname;
|
||||
@@ -63,6 +63,7 @@ export default async function createCustomProxyAgent(
|
||||
interceptors: {
|
||||
Client: [noProxyInterceptor],
|
||||
},
|
||||
keepAliveTimeout: 5000,
|
||||
});
|
||||
|
||||
setGlobalDispatcher(proxyAgent);
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import type { MainSettings } from '@server/lib/settings';
|
||||
import type { AllSettings, NetworkSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
|
||||
class RestartFlag {
|
||||
private settings: MainSettings;
|
||||
private networkSettings: NetworkSettings;
|
||||
|
||||
public initializeSettings(settings: MainSettings): void {
|
||||
this.settings = { ...settings };
|
||||
public initializeSettings(settings: AllSettings): void {
|
||||
this.networkSettings = {
|
||||
...settings.network,
|
||||
proxy: { ...settings.network.proxy },
|
||||
};
|
||||
}
|
||||
|
||||
public isSet(): boolean {
|
||||
const settings = getSettings().main;
|
||||
const networkSettings = getSettings().network;
|
||||
|
||||
return (
|
||||
this.settings.csrfProtection !== settings.csrfProtection ||
|
||||
this.settings.trustProxy !== settings.trustProxy ||
|
||||
this.settings.proxy.enabled !== settings.proxy.enabled ||
|
||||
this.settings.forceIpv4First !== settings.forceIpv4First ||
|
||||
this.settings.dnsServers !== settings.dnsServers
|
||||
this.networkSettings.csrfProtection !== networkSettings.csrfProtection ||
|
||||
this.networkSettings.trustProxy !== networkSettings.trustProxy ||
|
||||
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled ||
|
||||
this.networkSettings.forceIpv4First !== networkSettings.forceIpv4First ||
|
||||
this.networkSettings.dnsServers !== networkSettings.dnsServers
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- ***** BEGIN LICENSE BLOCK *****
|
||||
- Part of the Jellyfin project (https://jellyfin.media)
|
||||
-
|
||||
- All copyright belongs to the Jellyfin contributors; a full list can
|
||||
- be found in the file CONTRIBUTORS.md
|
||||
-
|
||||
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
|
||||
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
|
||||
- ***** END LICENSE BLOCK ***** -->
|
||||
<svg version="1.1" id="icon-transparent" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="110.25" y1="213.3" x2="496.14" y2="436.09">
|
||||
<stop offset="0" style="stop-color:#AA5CC3"/>
|
||||
<stop offset="1" style="stop-color:#00A4DC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<title>icon-transparent</title>
|
||||
<g id="icon-transparent">
|
||||
<path id="inner-shape" d="M256,201.6c-20.4,0-86.2,119.3-76.2,139.4s142.5,19.9,152.4,0S276.5,201.6,256,201.6z" fill="url(#linear-gradient)"/>
|
||||
<path id="outer-shape" d="M256,23.3c-61.6,0-259.8,359.4-229.6,420.1s429.3,60,459.2,0S317.6,23.3,256,23.3z
|
||||
M406.5,390.8c-19.6,39.3-281.1,39.8-300.9,0s110.1-275.3,150.4-275.3S426.1,351.4,406.5,390.8z" fill="url(#linear-gradient)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +1,5 @@
|
||||
import type { ForwardedRef } from 'react';
|
||||
import React from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export type ButtonType =
|
||||
| 'default'
|
||||
@@ -98,7 +97,7 @@ function Button<P extends ElementTypes = 'button'>(
|
||||
if (as === 'a') {
|
||||
return (
|
||||
<a
|
||||
className={twMerge(buttonStyle)}
|
||||
className={buttonStyle.join(' ')}
|
||||
{...(props as React.ComponentProps<'a'>)}
|
||||
ref={ref as ForwardedRef<HTMLAnchorElement>}
|
||||
>
|
||||
@@ -108,7 +107,7 @@ function Button<P extends ElementTypes = 'button'>(
|
||||
} else {
|
||||
return (
|
||||
<button
|
||||
className={twMerge(buttonStyle)}
|
||||
className={buttonStyle.join(' ')}
|
||||
{...(props as React.ComponentProps<'button'>)}
|
||||
ref={ref as ForwardedRef<HTMLButtonElement>}
|
||||
>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Field } from 'formik';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface LabeledCheckboxProps {
|
||||
id: string;
|
||||
className?: string;
|
||||
label: string;
|
||||
description: string;
|
||||
onChange: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
|
||||
id,
|
||||
className,
|
||||
label,
|
||||
description,
|
||||
onChange,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={twMerge('relative flex items-start', className)}>
|
||||
<div className="flex h-6 items-center">
|
||||
<Field type="checkbox" id={id} name={id} onChange={onChange} />
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<label htmlFor="localLogin" className="block">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-white">{label}</span>
|
||||
<span className="font-normal text-gray-400">{description}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
/* can hold child checkboxes */
|
||||
children && <div className="mt-4 pl-10">{children}</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabeledCheckbox;
|
||||
@@ -1,39 +1,63 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
loginwithapp: 'Login with {appName}',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
hostname: '{mediaServerName} URL',
|
||||
port: 'Port',
|
||||
enablessl: 'Use SSL',
|
||||
urlBase: 'URL Base',
|
||||
email: 'Email',
|
||||
emailtooltip:
|
||||
'Address does not need to be associated with your {mediaServerName} instance.',
|
||||
validationhostrequired: '{mediaServerName} URL required',
|
||||
validationhostformat: 'Valid URL required',
|
||||
validationemailrequired: 'Email required',
|
||||
validationemailformat: 'Valid email required',
|
||||
validationusernamerequired: 'Username required',
|
||||
validationpasswordrequired: 'Password required',
|
||||
validationservertyperequired: 'Please select a server type',
|
||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||
validationPortRequired: 'You must provide a valid port number',
|
||||
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||
loginerror: 'Something went wrong while trying to sign in.',
|
||||
adminerror: 'You must use an admin account to sign in.',
|
||||
noadminerror: 'No admin user found on the server.',
|
||||
credentialerror: 'The username or password is incorrect.',
|
||||
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
||||
signingin: 'Signing In…',
|
||||
signingin: 'Signing in…',
|
||||
signin: 'Sign In',
|
||||
initialsigningin: 'Connecting…',
|
||||
initialsignin: 'Connect',
|
||||
forgotpassword: 'Forgot Password?',
|
||||
servertype: 'Server Type',
|
||||
back: 'Go back',
|
||||
});
|
||||
|
||||
interface JellyfinLoginProps {
|
||||
revalidate: () => void;
|
||||
initial?: boolean;
|
||||
serverType?: MediaServerType;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
revalidate,
|
||||
initial,
|
||||
serverType,
|
||||
onCancel,
|
||||
}) => {
|
||||
const toasts = useToasts();
|
||||
const intl = useIntl();
|
||||
@@ -48,29 +72,56 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
: 'Media Server',
|
||||
};
|
||||
|
||||
const LoginSchema = Yup.object().shape({
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
),
|
||||
password: Yup.string(),
|
||||
});
|
||||
const baseUrl = settings.currentSettings.jellyfinExternalHost
|
||||
? settings.currentSettings.jellyfinExternalHost
|
||||
: settings.currentSettings.jellyfinHost;
|
||||
const jellyfinForgotPasswordUrl =
|
||||
settings.currentSettings.jellyfinForgotPasswordUrl;
|
||||
if (initial) {
|
||||
const LoginSchema = Yup.object().shape({
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(
|
||||
messages.validationhostrequired,
|
||||
mediaServerFormatValues
|
||||
)
|
||||
),
|
||||
port: Yup.number().required(
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
urlBase: Yup.string()
|
||||
.test(
|
||||
'leading-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
||||
(value) => !value || value.startsWith('/')
|
||||
)
|
||||
.test(
|
||||
'trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
email: Yup.string()
|
||||
.email(intl.formatMessage(messages.validationemailformat))
|
||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
),
|
||||
password: Yup.string(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
hostname: '',
|
||||
port: 8096,
|
||||
useSsl: false,
|
||||
urlBase: '',
|
||||
email: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
// Check if serverType is either 'Jellyfin' or 'Emby'
|
||||
// if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
|
||||
// throw new Error('Invalid serverType'); // You can customize the error message
|
||||
// }
|
||||
|
||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -79,7 +130,12 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
body: JSON.stringify({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
email: values.username,
|
||||
hostname: values.hostname,
|
||||
port: values.port,
|
||||
useSsl: values.useSsl,
|
||||
urlBase: values.urlBase,
|
||||
email: values.email,
|
||||
serverType: serverType,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
@@ -109,6 +165,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
errorMessage = messages.loginerror;
|
||||
break;
|
||||
}
|
||||
|
||||
toasts.addToast(
|
||||
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
||||
{
|
||||
@@ -121,51 +178,303 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<>
|
||||
<Form>
|
||||
<div>
|
||||
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
|
||||
{intl.formatMessage(messages.loginwithapp, {
|
||||
appName: mediaServerFormatValues.mediaServerName,
|
||||
})}
|
||||
</h2>
|
||||
|
||||
<div className="mt-1 mb-4">
|
||||
<div className="form-input-field">
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
values,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => (
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<div className="flex flex-col sm:flex-row sm:gap-4">
|
||||
<div className="w-full">
|
||||
<label htmlFor="hostname" className="text-label">
|
||||
{intl.formatMessage(
|
||||
messages.hostname,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
{values.useSsl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
id="username"
|
||||
name="username"
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||
className="rounded-r-only flex-1"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.hostname,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && touched.username && (
|
||||
<div className="error">{errors.username}</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
<div className="error">{errors.hostname}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 mb-2">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="port" className="text-label">
|
||||
{intl.formatMessage(messages.port)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0">
|
||||
<Field
|
||||
id="port"
|
||||
name="port"
|
||||
inputMode="numeric"
|
||||
type="text"
|
||||
className="short flex-1"
|
||||
placeholder={intl.formatMessage(messages.port)}
|
||||
/>
|
||||
{errors.port && touched.port && (
|
||||
<div className="error">{errors.port}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="useSsl" className="text-label mt-2">
|
||||
{intl.formatMessage(messages.enablessl)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="useSsl"
|
||||
name="useSsl"
|
||||
type="checkbox"
|
||||
onChange={() => {
|
||||
setFieldValue('useSsl', !values.useSsl);
|
||||
setFieldValue('port', values.useSsl ? 8096 : 443);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="urlBase" className="text-label mt-1">
|
||||
{intl.formatMessage(messages.urlBase)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="urlBase"
|
||||
name="urlBase"
|
||||
placeholder={intl.formatMessage(messages.urlBase)}
|
||||
/>
|
||||
</div>
|
||||
{errors.urlBase && touched.urlBase && (
|
||||
<div className="error">{errors.urlBase}</div>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-label inline-flex gap-1 align-middle"
|
||||
>
|
||||
{intl.formatMessage(messages.email)}
|
||||
<span className="label-tip">
|
||||
<Tooltip
|
||||
content={intl.formatMessage(
|
||||
messages.emailtooltip,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
>
|
||||
<span className="tooltip-trigger">
|
||||
<InformationCircleIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.email)}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && touched.email && (
|
||||
<div className="error">{errors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor="username" className="text-label">
|
||||
{intl.formatMessage(messages.username)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && touched.username && (
|
||||
<div className="error">{errors.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor="password" className="text-label">
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flexrounded-md shadow-sm">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && touched.password && (
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex flex-row-reverse justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</Button>
|
||||
</span>
|
||||
{onCancel && (
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button buttonType="default" onClick={() => onCancel()}>
|
||||
<FormattedMessage {...messages.back} />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
} else {
|
||||
const LoginSchema = Yup.object().shape({
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
),
|
||||
password: Yup.string(),
|
||||
});
|
||||
const baseUrl = settings.currentSettings.jellyfinExternalHost
|
||||
? settings.currentSettings.jellyfinExternalHost
|
||||
: settings.currentSettings.jellyfinHost;
|
||||
const jellyfinForgotPasswordUrl =
|
||||
settings.currentSettings.jellyfinForgotPasswordUrl;
|
||||
return (
|
||||
<div>
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
email: values.username,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
let errorMessage = null;
|
||||
switch (errorData?.message) {
|
||||
case ApiErrorCode.InvalidUrl:
|
||||
errorMessage = messages.invalidurlerror;
|
||||
break;
|
||||
case ApiErrorCode.InvalidCredentials:
|
||||
errorMessage = messages.credentialerror;
|
||||
break;
|
||||
case ApiErrorCode.NotAdmin:
|
||||
errorMessage = messages.adminerror;
|
||||
break;
|
||||
case ApiErrorCode.NoAdminUser:
|
||||
errorMessage = messages.noadminerror;
|
||||
break;
|
||||
default:
|
||||
errorMessage = messages.loginerror;
|
||||
break;
|
||||
}
|
||||
toasts.addToast(
|
||||
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<>
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<label htmlFor="username" className="text-label">
|
||||
{intl.formatMessage(messages.username)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && touched.username && (
|
||||
<div className="error">{errors.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<label htmlFor="password" className="text-label">
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && touched.password && (
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
<div className="flex-grow"></div>
|
||||
{baseUrl && (
|
||||
<a
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
as="a"
|
||||
buttonType="ghost"
|
||||
href={
|
||||
jellyfinForgotPasswordUrl
|
||||
? `${jellyfinForgotPasswordUrl}`
|
||||
@@ -176,35 +485,31 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
: ''
|
||||
}forgotpassword.html`
|
||||
}
|
||||
className="pt-2 text-sm text-indigo-500 hover:text-indigo-400"
|
||||
>
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
</a>
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
className="mt-2 w-full shadow-sm"
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</span>
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default JellyfinLogin;
|
||||
|
||||
@@ -2,7 +2,10 @@ import Button from '@app/components/Common/Button';
|
||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
LifebuoyIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
@@ -10,7 +13,6 @@ import { useIntl } from 'react-intl';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
loginwithapp: 'Login with {appName}',
|
||||
username: 'Username',
|
||||
email: 'Email Address',
|
||||
password: 'Password',
|
||||
@@ -51,7 +53,6 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
||||
password: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/local', {
|
||||
@@ -77,24 +78,19 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
||||
<>
|
||||
<Form>
|
||||
<div>
|
||||
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
|
||||
{intl.formatMessage(messages.loginwithapp, {
|
||||
appName: settings.currentSettings.applicationTitle,
|
||||
})}
|
||||
</h2>
|
||||
|
||||
<div className="mt-1 mb-4">
|
||||
<label htmlFor="email" className="text-label">
|
||||
{intl.formatMessage(messages.email) +
|
||||
' / ' +
|
||||
intl.formatMessage(messages.username)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder={`${intl.formatMessage(
|
||||
messages.email
|
||||
)} / ${intl.formatMessage(messages.username)}`}
|
||||
type="text"
|
||||
inputMode="email"
|
||||
data-testid="email"
|
||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
{errors.email &&
|
||||
@@ -103,35 +99,25 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
||||
<div className="error">{errors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 mb-2">
|
||||
<label htmlFor="password" className="text-label">
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
autoComplete="current-password"
|
||||
data-testid="password"
|
||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{errors.password &&
|
||||
touched.password &&
|
||||
typeof errors.password === 'string' && (
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
<div className="flex-grow"></div>
|
||||
{passwordResetEnabled && (
|
||||
<Link
|
||||
href="/resetpassword"
|
||||
className="pt-2 text-sm text-indigo-500 hover:text-indigo-400"
|
||||
>
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
</Link>
|
||||
{errors.password &&
|
||||
touched.password &&
|
||||
typeof errors.password === 'string' && (
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{loginError && (
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
@@ -139,21 +125,37 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
data-testid="local-signin-button"
|
||||
className="mt-2 w-full shadow-sm"
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex flex-row-reverse justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
data-testid="local-signin-button"
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
{passwordResetEnabled && (
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Link href="/resetpassword" passHref legacyBehavior>
|
||||
<Button as="a" buttonType="ghost">
|
||||
<LifebuoyIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import PlexIcon from '@app/assets/services/plex.svg';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||
import usePlexLogin from '@app/hooks/usePlexLogin';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
loginwithapp: 'Login with {appName}',
|
||||
});
|
||||
|
||||
interface PlexLoginButtonProps {
|
||||
onAuthToken: (authToken: string) => void;
|
||||
isProcessing?: boolean;
|
||||
onError?: (message: string) => void;
|
||||
large?: boolean;
|
||||
}
|
||||
|
||||
const PlexLoginButton = ({
|
||||
onAuthToken,
|
||||
onError,
|
||||
isProcessing,
|
||||
large,
|
||||
}: PlexLoginButtonProps) => {
|
||||
const { loading, login } = usePlexLogin({ onAuthToken, onError });
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="relative flex-1 border-[#cc7b19] bg-[rgba(204,123,25,0.3)] hover:border-[#cc7b19] hover:bg-[rgba(204,123,25,0.7)] disabled:opacity-50"
|
||||
onClick={login}
|
||||
disabled={loading || isProcessing}
|
||||
data-testid="plex-login-button"
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute right-0 mr-4 h-4 w-4">
|
||||
<SmallLoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{large ? (
|
||||
<FormattedMessage
|
||||
{...messages.loginwithapp}
|
||||
values={{
|
||||
appName: <PlexIcon className="mt-[2px] ml-[0.35em] w-8" />,
|
||||
}}
|
||||
>
|
||||
{(chunks) => (
|
||||
<>
|
||||
{chunks.map((c) =>
|
||||
typeof c === 'string' ? <span>{c}</span> : c
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormattedMessage>
|
||||
) : (
|
||||
<PlexIcon className="w-8" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlexLoginButton;
|
||||
@@ -1,13 +1,9 @@
|
||||
import EmbyLogo from '@app/assets/services/emby-icon-only.svg';
|
||||
import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg';
|
||||
import PlexLogo from '@app/assets/services/plex.svg';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Accordion from '@app/components/Common/Accordion';
|
||||
import ImageFader from '@app/components/Common/ImageFader';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import LanguagePicker from '@app/components/Layout/LanguagePicker';
|
||||
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
|
||||
import LocalLogin from '@app/components/Login/LocalLogin';
|
||||
import PlexLoginButton from '@app/components/Login/PlexLoginButton';
|
||||
import PlexLoginButton from '@app/components/PlexLoginButton';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
@@ -16,10 +12,10 @@ import { XCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { useRouter } from 'next/dist/client/router';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { CSSTransition, SwitchTransition } from 'react-transition-group';
|
||||
import useSWR from 'swr';
|
||||
import JellyfinLogin from './JellyfinLogin';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
signin: 'Sign In',
|
||||
@@ -27,21 +23,16 @@ const messages = defineMessages('components.Login', {
|
||||
signinwithplex: 'Use your Plex account',
|
||||
signinwithjellyfin: 'Use your {mediaServerName} account',
|
||||
signinwithoverseerr: 'Use your {applicationTitle} account',
|
||||
orsigninwith: 'Or sign in with',
|
||||
});
|
||||
|
||||
const Login = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const { user, revalidate } = useUser();
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isProcessing, setProcessing] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||
const [mediaServerLogin, setMediaServerLogin] = useState(
|
||||
settings.currentSettings.mediaServerLogin
|
||||
);
|
||||
const { user, revalidate } = useUser();
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
|
||||
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
||||
// We take the token and attempt to sign in. If we get a success message, we will
|
||||
@@ -95,73 +86,14 @@ const Login = () => {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const mediaServerName =
|
||||
settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: undefined;
|
||||
|
||||
const MediaServerLogo =
|
||||
settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||
? PlexLogo
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? JellyfinLogo
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? EmbyLogo
|
||||
: undefined;
|
||||
|
||||
const isJellyfin =
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN ||
|
||||
settings.currentSettings.mediaServerType === MediaServerType.EMBY;
|
||||
const mediaServerLoginRef = useRef<HTMLDivElement>(null);
|
||||
const localLoginRef = useRef<HTMLDivElement>(null);
|
||||
const loginRef = mediaServerLogin ? mediaServerLoginRef : localLoginRef;
|
||||
|
||||
const loginFormVisible =
|
||||
(isJellyfin && settings.currentSettings.mediaServerLogin) ||
|
||||
settings.currentSettings.localLogin;
|
||||
const additionalLoginOptions = [
|
||||
settings.currentSettings.mediaServerLogin &&
|
||||
(settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
||||
<PlexLoginButton
|
||||
key="plex"
|
||||
isProcessing={isProcessing}
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
large={!isJellyfin && !settings.currentSettings.localLogin}
|
||||
/>
|
||||
) : (
|
||||
settings.currentSettings.localLogin &&
|
||||
(mediaServerLogin ? (
|
||||
<Button
|
||||
key="jellyseerr"
|
||||
data-testid="jellyseerr-login-button"
|
||||
className="flex-1 bg-transparent"
|
||||
onClick={() => setMediaServerLogin(false)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/os_icon.svg"
|
||||
alt={settings.currentSettings.applicationTitle}
|
||||
className="mr-2 h-5"
|
||||
/>
|
||||
<span>{settings.currentSettings.applicationTitle}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="mediaserver"
|
||||
data-testid="mediaserver-login-button"
|
||||
className="flex-1 bg-transparent"
|
||||
onClick={() => setMediaServerLogin(true)}
|
||||
>
|
||||
<MediaServerLogo />
|
||||
<span>{mediaServerName}</span>
|
||||
</Button>
|
||||
))
|
||||
)),
|
||||
].filter((o): o is JSX.Element => !!o);
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
|
||||
@@ -180,6 +112,9 @@ const Login = () => {
|
||||
<div className="relative h-48 w-full max-w-full">
|
||||
<Image src="/logo_stacked.svg" alt="Logo" fill />
|
||||
</div>
|
||||
<h2 className="mt-12 text-center text-3xl font-extrabold leading-9 text-gray-100">
|
||||
{intl.formatMessage(messages.signinheader)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div
|
||||
@@ -210,71 +145,65 @@ const Login = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div className="px-10 py-8">
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
key={mediaServerLogin ? 'ms' : 'local'}
|
||||
nodeRef={loginRef}
|
||||
addEndListener={(done) => {
|
||||
loginRef.current?.addEventListener(
|
||||
'transitionend',
|
||||
done,
|
||||
false
|
||||
);
|
||||
}}
|
||||
onEntered={() => {
|
||||
document
|
||||
.querySelector<HTMLInputElement>('#email, #username')
|
||||
?.focus();
|
||||
}}
|
||||
classNames={{
|
||||
appear: 'opacity-0',
|
||||
appearActive: 'transition-opacity duration-500 opacity-100',
|
||||
enter: 'opacity-0',
|
||||
enterActive: 'transition-opacity duration-500 opacity-100',
|
||||
exitActive: 'transition-opacity duration-0 opacity-0',
|
||||
}}
|
||||
>
|
||||
<div ref={loginRef} className="button-container">
|
||||
{isJellyfin &&
|
||||
(mediaServerLogin ||
|
||||
!settings.currentSettings.localLogin) ? (
|
||||
<JellyfinLogin
|
||||
serverType={settings.currentSettings.mediaServerType}
|
||||
revalidate={revalidate}
|
||||
/>
|
||||
) : (
|
||||
settings.currentSettings.localLogin && (
|
||||
<LocalLogin revalidate={revalidate} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
|
||||
{additionalLoginOptions.length > 0 &&
|
||||
(loginFormVisible ? (
|
||||
<div className="flex items-center py-5">
|
||||
<div className="flex-grow border-t border-gray-600"></div>
|
||||
<span className="mx-2 flex-shrink text-sm text-gray-400">
|
||||
{intl.formatMessage(messages.orsigninwith)}
|
||||
</span>
|
||||
<div className="flex-grow border-t border-gray-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<h2 className="mb-6 text-center text-lg font-bold text-neutral-200">
|
||||
{intl.formatMessage(messages.signinheader)}
|
||||
</h2>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={`flex w-full flex-wrap gap-2 ${
|
||||
!loginFormVisible ? 'flex-col' : ''
|
||||
}`}
|
||||
>
|
||||
{additionalLoginOptions}
|
||||
</div>
|
||||
</div>
|
||||
<Accordion single atLeastOne>
|
||||
{({ openIndexes, handleClick, AccordionContent }) => (
|
||||
<>
|
||||
<button
|
||||
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 focus:outline-none sm:rounded-t-lg ${
|
||||
openIndexes.includes(0) && 'text-indigo-500'
|
||||
} ${
|
||||
settings.currentSettings.localLogin &&
|
||||
'hover:cursor-pointer hover:bg-gray-700'
|
||||
}`}
|
||||
onClick={() => handleClick(0)}
|
||||
disabled={!settings.currentSettings.localLogin}
|
||||
>
|
||||
{settings.currentSettings.mediaServerType ==
|
||||
MediaServerType.PLEX
|
||||
? intl.formatMessage(messages.signinwithplex)
|
||||
: intl.formatMessage(
|
||||
messages.signinwithjellyfin,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
</button>
|
||||
<AccordionContent isOpen={openIndexes.includes(0)}>
|
||||
<div className="px-10 py-8">
|
||||
{settings.currentSettings.mediaServerType ==
|
||||
MediaServerType.PLEX ? (
|
||||
<PlexLoginButton
|
||||
isProcessing={isProcessing}
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinLogin revalidate={revalidate} />
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
{settings.currentSettings.localLogin && (
|
||||
<div>
|
||||
<button
|
||||
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
|
||||
openIndexes.includes(1)
|
||||
? 'text-indigo-500'
|
||||
: 'sm:rounded-b-lg'
|
||||
}`}
|
||||
onClick={() => handleClick(1)}
|
||||
>
|
||||
{intl.formatMessage(messages.signinwithoverseerr, {
|
||||
applicationTitle:
|
||||
settings.currentSettings.applicationTitle,
|
||||
})}
|
||||
</button>
|
||||
<AccordionContent isOpen={openIndexes.includes(1)}>
|
||||
<div className="px-10 py-8">
|
||||
<LocalLogin revalidate={revalidate} />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Accordion>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +190,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
})
|
||||
) {
|
||||
mediaLinks.push({
|
||||
text: getAvalaibleMediaServerName(),
|
||||
text: getAvailableMediaServerName(),
|
||||
url: plexUrl,
|
||||
svg: <PlayIcon />,
|
||||
});
|
||||
@@ -204,7 +204,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
})
|
||||
) {
|
||||
mediaLinks.push({
|
||||
text: getAvalaible4kMediaServerName(),
|
||||
text: getAvailable4kMediaServerName(),
|
||||
url: plexUrl4k,
|
||||
svg: <PlayIcon />,
|
||||
});
|
||||
@@ -292,7 +292,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
(provider) => provider.iso_3166_1 === streamingRegion
|
||||
)?.flatrate ?? [];
|
||||
|
||||
function getAvalaibleMediaServerName() {
|
||||
function getAvailableMediaServerName() {
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||
}
|
||||
@@ -304,7 +304,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
|
||||
}
|
||||
|
||||
function getAvalaible4kMediaServerName() {
|
||||
function getAvailable4kMediaServerName() {
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||
}
|
||||
|
||||
66
src/components/PlexLoginButton/index.tsx
Normal file
66
src/components/PlexLoginButton/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import PlexOAuth from '@app/utils/plex';
|
||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.PlexLoginButton', {
|
||||
signinwithplex: 'Sign In',
|
||||
signingin: 'Signing In…',
|
||||
});
|
||||
|
||||
const plexOAuth = new PlexOAuth();
|
||||
|
||||
interface PlexLoginButtonProps {
|
||||
onAuthToken: (authToken: string) => void;
|
||||
isProcessing?: boolean;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
const PlexLoginButton = ({
|
||||
onAuthToken,
|
||||
onError,
|
||||
isProcessing,
|
||||
}: PlexLoginButtonProps) => {
|
||||
const intl = useIntl();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getPlexLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const authToken = await plexOAuth.login();
|
||||
setLoading(false);
|
||||
onAuthToken(authToken);
|
||||
} catch (e) {
|
||||
if (onError) {
|
||||
onError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<span className="block w-full rounded-md shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
plexOAuth.preparePopup();
|
||||
setTimeout(() => getPlexLogin(), 1500);
|
||||
}}
|
||||
disabled={loading || isProcessing}
|
||||
className="plex-button"
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon />
|
||||
<span>
|
||||
{loading
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: isProcessing
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signinwithplex)}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlexLoginButton;
|
||||
@@ -13,6 +13,7 @@ const messages = defineMessages('components.Settings', {
|
||||
menuPlexSettings: 'Plex',
|
||||
menuJellyfinSettings: '{mediaServerName}',
|
||||
menuServices: 'Services',
|
||||
menuNetwork: 'Network',
|
||||
menuNotifications: 'Notifications',
|
||||
menuLogs: 'Logs',
|
||||
menuJobs: 'Jobs & Cache',
|
||||
@@ -53,6 +54,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
|
||||
route: '/settings/services',
|
||||
regex: /^\/settings\/services/,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.menuNetwork),
|
||||
route: '/settings/network',
|
||||
regex: /^\/settings\/network/,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.menuNotifications),
|
||||
route: '/settings/notifications/email',
|
||||
|
||||
@@ -2,7 +2,6 @@ import Button from '@app/components/Common/Button';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import LanguageSelector from '@app/components/LanguageSelector';
|
||||
import RegionSelector from '@app/components/RegionSelector';
|
||||
import CopyButton from '@app/components/Settings/CopyButton';
|
||||
@@ -42,39 +41,15 @@ const messages = defineMessages('components.Settings.SettingsMain', {
|
||||
toastSettingsSuccess: 'Settings saved successfully!',
|
||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||
hideAvailable: 'Hide Available Media',
|
||||
csrfProtection: 'Enable CSRF Protection',
|
||||
csrfProtectionTip: 'Set external API access to read-only (requires HTTPS)',
|
||||
csrfProtectionHoverTip:
|
||||
'Do NOT enable this setting unless you understand what you are doing!',
|
||||
cacheImages: 'Enable Image Caching',
|
||||
cacheImagesTip:
|
||||
'Cache externally sourced images (requires a significant amount of disk space)',
|
||||
trustProxy: 'Enable Proxy Support',
|
||||
trustProxyTip:
|
||||
'Allow Jellyseerr to correctly register client IP addresses behind a proxy',
|
||||
validationApplicationTitle: 'You must provide an application title',
|
||||
validationApplicationUrl: 'You must provide a valid URL',
|
||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
partialRequestsEnabled: 'Allow Partial Series Requests',
|
||||
enableSpecialEpisodes: 'Allow Special Episodes Requests',
|
||||
forceIpv4First: 'IPv4 Resolution First',
|
||||
forceIpv4FirstTip:
|
||||
'Force Jellyseerr to resolve IPv4 addresses first instead of IPv6',
|
||||
dnsServers: 'Custom DNS Servers',
|
||||
dnsServersTip:
|
||||
'Comma-separated list of custom DNS servers, e.g. "1.1.1.1,[2606:4700:4700::1111]"',
|
||||
locale: 'Display Language',
|
||||
proxyEnabled: 'HTTP(S) Proxy',
|
||||
proxyHostname: 'Proxy Hostname',
|
||||
proxyPort: 'Proxy Port',
|
||||
proxySsl: 'Use SSL For Proxy',
|
||||
proxyUser: 'Proxy Username',
|
||||
proxyPassword: 'Proxy Password',
|
||||
proxyBypassFilter: 'Proxy Ignored Addresses',
|
||||
proxyBypassFilterTip:
|
||||
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
||||
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
|
||||
validationProxyPort: 'You must provide a valid port',
|
||||
});
|
||||
|
||||
const SettingsMain = () => {
|
||||
@@ -105,12 +80,6 @@ const SettingsMain = () => {
|
||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
proxyPort: Yup.number().when('proxyEnabled', {
|
||||
is: (proxyEnabled: boolean) => proxyEnabled,
|
||||
then: Yup.number().required(
|
||||
intl.formatMessage(messages.validationProxyPort)
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const regenerate = async () => {
|
||||
@@ -158,7 +127,6 @@ const SettingsMain = () => {
|
||||
initialValues={{
|
||||
applicationTitle: data?.applicationTitle,
|
||||
applicationUrl: data?.applicationUrl,
|
||||
csrfProtection: data?.csrfProtection,
|
||||
hideAvailable: data?.hideAvailable,
|
||||
locale: data?.locale ?? 'en',
|
||||
discoverRegion: data?.discoverRegion,
|
||||
@@ -166,18 +134,7 @@ const SettingsMain = () => {
|
||||
streamingRegion: data?.streamingRegion || 'US',
|
||||
partialRequestsEnabled: data?.partialRequestsEnabled,
|
||||
enableSpecialEpisodes: data?.enableSpecialEpisodes,
|
||||
forceIpv4First: data?.forceIpv4First,
|
||||
dnsServers: data?.dnsServers,
|
||||
trustProxy: data?.trustProxy,
|
||||
cacheImages: data?.cacheImages,
|
||||
proxyEnabled: data?.proxy?.enabled,
|
||||
proxyHostname: data?.proxy?.hostname,
|
||||
proxyPort: data?.proxy?.port,
|
||||
proxySsl: data?.proxy?.useSsl,
|
||||
proxyUser: data?.proxy?.user,
|
||||
proxyPassword: data?.proxy?.password,
|
||||
proxyBypassFilter: data?.proxy?.bypassFilter,
|
||||
proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses,
|
||||
}}
|
||||
enableReinitialize
|
||||
validationSchema={MainSettingsSchema}
|
||||
@@ -191,7 +148,6 @@ const SettingsMain = () => {
|
||||
body: JSON.stringify({
|
||||
applicationTitle: values.applicationTitle,
|
||||
applicationUrl: values.applicationUrl,
|
||||
csrfProtection: values.csrfProtection,
|
||||
hideAvailable: values.hideAvailable,
|
||||
locale: values.locale,
|
||||
discoverRegion: values.discoverRegion,
|
||||
@@ -199,20 +155,7 @@ const SettingsMain = () => {
|
||||
originalLanguage: values.originalLanguage,
|
||||
partialRequestsEnabled: values.partialRequestsEnabled,
|
||||
enableSpecialEpisodes: values.enableSpecialEpisodes,
|
||||
forceIpv4First: values.forceIpv4First,
|
||||
dnsServers: values.dnsServers,
|
||||
trustProxy: values.trustProxy,
|
||||
cacheImages: values.cacheImages,
|
||||
proxy: {
|
||||
enabled: values.proxyEnabled,
|
||||
hostname: values.proxyHostname,
|
||||
port: values.proxyPort,
|
||||
useSsl: values.proxySsl,
|
||||
user: values.proxyUser,
|
||||
password: values.proxyPassword,
|
||||
bypassFilter: values.proxyBypassFilter,
|
||||
bypassLocalAddresses: values.proxyBypassLocalAddresses,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
@@ -321,58 +264,6 @@ const SettingsMain = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="trustProxy" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.trustProxy)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.trustProxyTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="trustProxy"
|
||||
name="trustProxy"
|
||||
onChange={() => {
|
||||
setFieldValue('trustProxy', !values.trustProxy);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="csrfProtection" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.csrfProtection)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.csrfProtectionTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Tooltip
|
||||
content={intl.formatMessage(
|
||||
messages.csrfProtectionHoverTip
|
||||
)}
|
||||
>
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="csrfProtection"
|
||||
name="csrfProtection"
|
||||
onChange={() => {
|
||||
setFieldValue(
|
||||
'csrfProtection',
|
||||
!values.csrfProtection
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="cacheImages" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
@@ -534,231 +425,6 @@ const SettingsMain = () => {
|
||||
/>
|
||||
</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" />
|
||||
<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="dnsServers" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.dnsServers)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.dnsServersTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="dnsServers"
|
||||
name="dnsServers"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.dnsServers &&
|
||||
touched.dnsServers &&
|
||||
typeof errors.dnsServers === 'string' && (
|
||||
<div className="error">{errors.dnsServers}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyEnabled" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.proxyEnabled)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="proxyEnabled"
|
||||
name="proxyEnabled"
|
||||
onChange={() => {
|
||||
setFieldValue('proxyEnabled', !values.proxyEnabled);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{values.proxyEnabled && (
|
||||
<>
|
||||
<div className="mr-2 ml-4">
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="proxyHostname"
|
||||
className="checkbox-label"
|
||||
>
|
||||
{intl.formatMessage(messages.proxyHostname)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyHostname"
|
||||
name="proxyHostname"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyHostname &&
|
||||
touched.proxyHostname &&
|
||||
typeof errors.proxyHostname === 'string' && (
|
||||
<div className="error">
|
||||
{errors.proxyHostname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyPort" className="checkbox-label">
|
||||
{intl.formatMessage(messages.proxyPort)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyPort"
|
||||
name="proxyPort"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyPort &&
|
||||
touched.proxyPort &&
|
||||
typeof errors.proxyPort === 'string' && (
|
||||
<div className="error">{errors.proxyPort}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxySsl" className="checkbox-label">
|
||||
{intl.formatMessage(messages.proxySsl)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="proxySsl"
|
||||
name="proxySsl"
|
||||
onChange={() => {
|
||||
setFieldValue('proxySsl', !values.proxySsl);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyUser" className="checkbox-label">
|
||||
{intl.formatMessage(messages.proxyUser)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyUser"
|
||||
name="proxyUser"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyUser &&
|
||||
touched.proxyUser &&
|
||||
typeof errors.proxyUser === 'string' && (
|
||||
<div className="error">{errors.proxyUser}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="proxyPassword"
|
||||
className="checkbox-label"
|
||||
>
|
||||
{intl.formatMessage(messages.proxyPassword)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyPassword"
|
||||
name="proxyPassword"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyPassword &&
|
||||
touched.proxyPassword &&
|
||||
typeof errors.proxyPassword === 'string' && (
|
||||
<div className="error">
|
||||
{errors.proxyPassword}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="proxyBypassFilter"
|
||||
className="checkbox-label"
|
||||
>
|
||||
{intl.formatMessage(messages.proxyBypassFilter)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.proxyBypassFilterTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyBypassFilter"
|
||||
name="proxyBypassFilter"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyBypassFilter &&
|
||||
touched.proxyBypassFilter &&
|
||||
typeof errors.proxyBypassFilter === 'string' && (
|
||||
<div className="error">
|
||||
{errors.proxyBypassFilter}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="proxyBypassLocalAddresses"
|
||||
className="checkbox-label"
|
||||
>
|
||||
{intl.formatMessage(
|
||||
messages.proxyBypassLocalAddresses
|
||||
)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="proxyBypassLocalAddresses"
|
||||
name="proxyBypassLocalAddresses"
|
||||
onChange={() => {
|
||||
setFieldValue(
|
||||
'proxyBypassLocalAddresses',
|
||||
!values.proxyBypassLocalAddresses
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
|
||||
461
src/components/Settings/SettingsNetwork/index.tsx
Normal file
461
src/components/Settings/SettingsNetwork/index.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import SettingsBadge from '@app/components/Settings/SettingsBadge';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||
import type { NetworkSettings } from '@server/lib/settings';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages('components.Settings.SettingsNetwork', {
|
||||
toastSettingsSuccess: 'Settings saved successfully!',
|
||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||
network: 'Network',
|
||||
networksettings: 'Network Settings',
|
||||
networksettingsDescription:
|
||||
'Configure network settings for your Jellyseerr instance.',
|
||||
csrfProtection: 'Enable CSRF Protection',
|
||||
csrfProtectionTip: 'Set external API access to read-only (requires HTTPS)',
|
||||
csrfProtectionHoverTip:
|
||||
'Do NOT enable this setting unless you understand what you are doing!',
|
||||
trustProxy: 'Enable Proxy Support',
|
||||
trustProxyTip:
|
||||
'Allow Jellyseerr to correctly register client IP addresses behind a proxy',
|
||||
forceIpv4First: 'IPv4 Resolution First',
|
||||
forceIpv4FirstTip:
|
||||
'Force Jellyseerr to resolve IPv4 addresses first instead of IPv6',
|
||||
dnsServers: 'Custom DNS Servers',
|
||||
dnsServersTip:
|
||||
'Comma-separated list of custom DNS servers, e.g. "1.1.1.1,[2606:4700:4700::1111]"',
|
||||
proxyEnabled: 'HTTP(S) Proxy',
|
||||
proxyHostname: 'Proxy Hostname',
|
||||
proxyPort: 'Proxy Port',
|
||||
proxySsl: 'Use SSL For Proxy',
|
||||
proxyUser: 'Proxy Username',
|
||||
proxyPassword: 'Proxy Password',
|
||||
proxyBypassFilter: 'Proxy Ignored Addresses',
|
||||
proxyBypassFilterTip:
|
||||
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
||||
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
|
||||
validationProxyPort: 'You must provide a valid port',
|
||||
});
|
||||
|
||||
const SettingsMain = () => {
|
||||
const { addToast } = useToasts();
|
||||
const intl = useIntl();
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<NetworkSettings>('/api/v1/settings/network');
|
||||
|
||||
const NetworkSettingsSchema = Yup.object().shape({
|
||||
proxyPort: Yup.number().when('proxyEnabled', {
|
||||
is: (proxyEnabled: boolean) => proxyEnabled,
|
||||
then: Yup.number().required(
|
||||
intl.formatMessage(messages.validationProxyPort)
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title={[
|
||||
intl.formatMessage(messages.network),
|
||||
intl.formatMessage(globalMessages.settings),
|
||||
]}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.networksettings)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.networksettingsDescription)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section">
|
||||
<Formik
|
||||
initialValues={{
|
||||
csrfProtection: data?.csrfProtection,
|
||||
forceIpv4First: data?.forceIpv4First,
|
||||
dnsServers: data?.dnsServers,
|
||||
trustProxy: data?.trustProxy,
|
||||
proxyEnabled: data?.proxy?.enabled,
|
||||
proxyHostname: data?.proxy?.hostname,
|
||||
proxyPort: data?.proxy?.port,
|
||||
proxySsl: data?.proxy?.useSsl,
|
||||
proxyUser: data?.proxy?.user,
|
||||
proxyPassword: data?.proxy?.password,
|
||||
proxyBypassFilter: data?.proxy?.bypassFilter,
|
||||
proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses,
|
||||
}}
|
||||
enableReinitialize
|
||||
validationSchema={NetworkSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/settings/network', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
csrfProtection: values.csrfProtection,
|
||||
forceIpv4First: values.forceIpv4First,
|
||||
dnsServers: values.dnsServers,
|
||||
trustProxy: values.trustProxy,
|
||||
proxy: {
|
||||
enabled: values.proxyEnabled,
|
||||
hostname: values.proxyHostname,
|
||||
port: values.proxyPort,
|
||||
useSsl: values.proxySsl,
|
||||
user: values.proxyUser,
|
||||
password: values.proxyPassword,
|
||||
bypassFilter: values.proxyBypassFilter,
|
||||
bypassLocalAddresses: values.proxyBypassLocalAddresses,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
mutate('/api/v1/settings/public');
|
||||
mutate('/api/v1/status');
|
||||
|
||||
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.toastSettingsFailure), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
values,
|
||||
setFieldValue,
|
||||
}) => {
|
||||
return (
|
||||
<Form className="section" data-testid="settings-network-form">
|
||||
<div className="form-row">
|
||||
<label htmlFor="trustProxy" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.trustProxy)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.trustProxyTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="trustProxy"
|
||||
name="trustProxy"
|
||||
onChange={() => {
|
||||
setFieldValue('trustProxy', !values.trustProxy);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="csrfProtection" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.csrfProtection)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.csrfProtectionTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Tooltip
|
||||
content={intl.formatMessage(
|
||||
messages.csrfProtectionHoverTip
|
||||
)}
|
||||
>
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="csrfProtection"
|
||||
name="csrfProtection"
|
||||
onChange={() => {
|
||||
setFieldValue(
|
||||
'csrfProtection',
|
||||
!values.csrfProtection
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</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" />
|
||||
<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="dnsServers" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.dnsServers)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.dnsServersTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="dnsServers"
|
||||
name="dnsServers"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.dnsServers &&
|
||||
touched.dnsServers &&
|
||||
typeof errors.dnsServers === 'string' && (
|
||||
<div className="error">{errors.dnsServers}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyEnabled" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.proxyEnabled)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="proxyEnabled"
|
||||
name="proxyEnabled"
|
||||
onChange={() => {
|
||||
setFieldValue('proxyEnabled', !values.proxyEnabled);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{values.proxyEnabled && (
|
||||
<>
|
||||
<div className="mr-2 ml-4">
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="proxyHostname"
|
||||
className="checkbox-label"
|
||||
>
|
||||
{intl.formatMessage(messages.proxyHostname)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyHostname"
|
||||
name="proxyHostname"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyHostname &&
|
||||
touched.proxyHostname &&
|
||||
typeof errors.proxyHostname === 'string' && (
|
||||
<div className="error">
|
||||
{errors.proxyHostname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyPort" className="checkbox-label">
|
||||
{intl.formatMessage(messages.proxyPort)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyPort"
|
||||
name="proxyPort"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyPort &&
|
||||
touched.proxyPort &&
|
||||
typeof errors.proxyPort === 'string' && (
|
||||
<div className="error">{errors.proxyPort}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxySsl" className="checkbox-label">
|
||||
{intl.formatMessage(messages.proxySsl)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="proxySsl"
|
||||
name="proxySsl"
|
||||
onChange={() => {
|
||||
setFieldValue('proxySsl', !values.proxySsl);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyUser" className="checkbox-label">
|
||||
{intl.formatMessage(messages.proxyUser)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyUser"
|
||||
name="proxyUser"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyUser &&
|
||||
touched.proxyUser &&
|
||||
typeof errors.proxyUser === 'string' && (
|
||||
<div className="error">{errors.proxyUser}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="proxyPassword"
|
||||
className="checkbox-label"
|
||||
>
|
||||
{intl.formatMessage(messages.proxyPassword)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyPassword"
|
||||
name="proxyPassword"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyPassword &&
|
||||
touched.proxyPassword &&
|
||||
typeof errors.proxyPassword === 'string' && (
|
||||
<div className="error">
|
||||
{errors.proxyPassword}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="proxyBypassFilter"
|
||||
className="checkbox-label"
|
||||
>
|
||||
{intl.formatMessage(messages.proxyBypassFilter)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.proxyBypassFilterTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="proxyBypassFilter"
|
||||
name="proxyBypassFilter"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyBypassFilter &&
|
||||
touched.proxyBypassFilter &&
|
||||
typeof errors.proxyBypassFilter === 'string' && (
|
||||
<div className="error">
|
||||
{errors.proxyBypassFilter}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label
|
||||
htmlFor="proxyBypassLocalAddresses"
|
||||
className="checkbox-label"
|
||||
>
|
||||
{intl.formatMessage(
|
||||
messages.proxyBypassLocalAddresses
|
||||
)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="proxyBypassLocalAddresses"
|
||||
name="proxyBypassLocalAddresses"
|
||||
onChange={() => {
|
||||
setFieldValue(
|
||||
'proxyBypassLocalAddresses',
|
||||
!values.proxyBypassLocalAddresses
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
<ArrowDownOnSquareIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMain;
|
||||
@@ -1,5 +1,4 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import LabeledCheckbox from '@app/components/Common/LabeledCheckbox';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import PermissionEdit from '@app/components/PermissionEdit';
|
||||
@@ -14,7 +13,6 @@ import { Field, Form, Formik } from 'formik';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import * as yup from 'yup';
|
||||
|
||||
const messages = defineMessages('components.Settings.SettingsUsers', {
|
||||
users: 'Users',
|
||||
@@ -22,15 +20,9 @@ const messages = defineMessages('components.Settings.SettingsUsers', {
|
||||
userSettingsDescription: 'Configure global and default user settings.',
|
||||
toastSettingsSuccess: 'User settings saved successfully!',
|
||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||
loginMethods: 'Login Methods',
|
||||
loginMethodsTip: 'Configure login methods for users.',
|
||||
localLogin: 'Enable Local Sign-In',
|
||||
localLoginTip:
|
||||
'Allow users to sign in using their email address and password',
|
||||
mediaServerLogin: 'Enable {mediaServerName} Sign-In',
|
||||
mediaServerLoginTip:
|
||||
'Allow users to sign in using their {mediaServerName} account',
|
||||
atLeastOneAuth: 'At least one authentication method must be selected.',
|
||||
'Allow users to sign in using their email address and password, instead of {mediaServerName} OAuth',
|
||||
newPlexLogin: 'Enable New {mediaServerName} Sign-In',
|
||||
newPlexLoginTip:
|
||||
'Allow {mediaServerName} users to sign in without first being imported',
|
||||
@@ -50,27 +42,6 @@ const SettingsUsers = () => {
|
||||
} = useSWR<MainSettings>('/api/v1/settings/main');
|
||||
const settings = useSettings();
|
||||
|
||||
const schema = yup
|
||||
.object()
|
||||
.shape({
|
||||
localLogin: yup.boolean(),
|
||||
mediaServerLogin: yup.boolean(),
|
||||
})
|
||||
.test({
|
||||
name: 'atLeastOneAuth',
|
||||
test: function (values) {
|
||||
const isValid = ['localLogin', 'mediaServerLogin'].some(
|
||||
(field) => !!values[field]
|
||||
);
|
||||
|
||||
if (isValid) return true;
|
||||
return this.createError({
|
||||
path: 'localLogin | mediaServerLogin',
|
||||
message: intl.formatMessage(messages.atLeastOneAuth),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -81,8 +52,6 @@ const SettingsUsers = () => {
|
||||
? 'Jellyfin'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
@@ -104,7 +73,6 @@ const SettingsUsers = () => {
|
||||
<Formik
|
||||
initialValues={{
|
||||
localLogin: data?.localLogin,
|
||||
mediaServerLogin: data?.mediaServerLogin,
|
||||
newPlexLogin: data?.newPlexLogin,
|
||||
movieQuotaLimit: data?.defaultQuotas.movie.quotaLimit ?? 0,
|
||||
movieQuotaDays: data?.defaultQuotas.movie.quotaDays ?? 7,
|
||||
@@ -112,7 +80,6 @@ const SettingsUsers = () => {
|
||||
tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7,
|
||||
defaultPermissions: data?.defaultPermissions ?? 0,
|
||||
}}
|
||||
validationSchema={schema}
|
||||
enableReinitialize
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
@@ -123,7 +90,6 @@ const SettingsUsers = () => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
localLogin: values.localLogin,
|
||||
mediaServerLogin: values.mediaServerLogin,
|
||||
newPlexLogin: values.newPlexLogin,
|
||||
defaultQuotas: {
|
||||
movie: {
|
||||
@@ -155,61 +121,30 @@ const SettingsUsers = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, isValid, values, errors, setFieldValue }) => {
|
||||
{({ isSubmitting, values, setFieldValue }) => {
|
||||
return (
|
||||
<Form className="section">
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="group-label"
|
||||
className="form-group"
|
||||
>
|
||||
<div className="form-row">
|
||||
<span id="group-label" className="group-label">
|
||||
{intl.formatMessage(messages.loginMethods)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.loginMethodsTip)}
|
||||
</span>
|
||||
{'localLogin | mediaServerLogin' in errors && (
|
||||
<span className="error">
|
||||
{errors['localLogin | mediaServerLogin'] as string}
|
||||
</span>
|
||||
<div className="form-row">
|
||||
<label htmlFor="localLogin" className="checkbox-label">
|
||||
{intl.formatMessage(messages.localLogin)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(
|
||||
messages.localLoginTip,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="form-input-area max-w-lg">
|
||||
<LabeledCheckbox
|
||||
id="localLogin"
|
||||
label={intl.formatMessage(messages.localLogin)}
|
||||
description={intl.formatMessage(
|
||||
messages.localLoginTip,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
onChange={() =>
|
||||
setFieldValue('localLogin', !values.localLogin)
|
||||
}
|
||||
/>
|
||||
<LabeledCheckbox
|
||||
id="mediaServerLogin"
|
||||
className="mt-4"
|
||||
label={intl.formatMessage(
|
||||
messages.mediaServerLogin,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
description={intl.formatMessage(
|
||||
messages.mediaServerLoginTip,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
onChange={() =>
|
||||
setFieldValue(
|
||||
'mediaServerLogin',
|
||||
!values.mediaServerLogin
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="localLogin"
|
||||
name="localLogin"
|
||||
onChange={() => {
|
||||
setFieldValue('localLogin', !values.localLogin);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="newPlexLogin" className="checkbox-label">
|
||||
{intl.formatMessage(
|
||||
@@ -294,7 +229,7 @@ const SettingsUsers = () => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowDownOnSquareIcon />
|
||||
<span>
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
hostname: '{mediaServerName} URL',
|
||||
port: 'Port',
|
||||
enablessl: 'Use SSL',
|
||||
urlBase: 'URL Base',
|
||||
email: 'Email Address',
|
||||
emailtooltip:
|
||||
'Address does not need to be associated with your {mediaServerName} instance.',
|
||||
validationhostrequired: '{mediaServerName} URL required',
|
||||
validationhostformat: 'Valid URL required',
|
||||
validationemailrequired: 'You must provide a valid email address',
|
||||
validationemailformat: 'Valid email required',
|
||||
validationusernamerequired: 'Username required',
|
||||
validationpasswordrequired: 'You must provide a password',
|
||||
validationservertyperequired: 'Please select a server type',
|
||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||
validationPortRequired: 'You must provide a valid port number',
|
||||
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||
loginerror: 'Something went wrong while trying to sign in.',
|
||||
adminerror: 'You must use an admin account to sign in.',
|
||||
noadminerror: 'No admin user found on the server.',
|
||||
credentialerror: 'The username or password is incorrect.',
|
||||
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
||||
signingin: 'Signing In…',
|
||||
signin: 'Sign In',
|
||||
initialsigningin: 'Connecting…',
|
||||
initialsignin: 'Connect',
|
||||
forgotpassword: 'Forgot Password?',
|
||||
servertype: 'Server Type',
|
||||
back: 'Go back',
|
||||
});
|
||||
|
||||
interface JellyfinSetupProps {
|
||||
revalidate: () => void;
|
||||
serverType?: MediaServerType;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
function JellyfinSetup({
|
||||
revalidate,
|
||||
serverType,
|
||||
onCancel,
|
||||
}: JellyfinSetupProps) {
|
||||
const toasts = useToasts();
|
||||
const intl = useIntl();
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
serverType === MediaServerType.JELLYFIN
|
||||
? ServerType.JELLYFIN
|
||||
: serverType === MediaServerType.EMBY
|
||||
? ServerType.EMBY
|
||||
: 'Media Server',
|
||||
};
|
||||
|
||||
const LoginSchema = Yup.object().shape({
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(
|
||||
messages.validationhostrequired,
|
||||
mediaServerFormatValues
|
||||
)
|
||||
),
|
||||
port: Yup.number().required(
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
urlBase: Yup.string()
|
||||
.test(
|
||||
'leading-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
||||
(value) => !value || value.startsWith('/')
|
||||
)
|
||||
.test(
|
||||
'trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
email: Yup.string()
|
||||
.email(intl.formatMessage(messages.validationemailformat))
|
||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
),
|
||||
password: Yup.string(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
hostname: '',
|
||||
port: 8096,
|
||||
useSsl: false,
|
||||
urlBase: '',
|
||||
email: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
// Check if serverType is either 'Jellyfin' or 'Emby'
|
||||
// if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
|
||||
// throw new Error('Invalid serverType'); // You can customize the error message
|
||||
// }
|
||||
|
||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
hostname: values.hostname,
|
||||
port: values.port,
|
||||
useSsl: values.useSsl,
|
||||
urlBase: values.urlBase,
|
||||
email: values.email,
|
||||
serverType: serverType,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
let errorMessage = null;
|
||||
switch (errorData?.message) {
|
||||
case ApiErrorCode.InvalidUrl:
|
||||
errorMessage = messages.invalidurlerror;
|
||||
break;
|
||||
case ApiErrorCode.InvalidCredentials:
|
||||
errorMessage = messages.credentialerror;
|
||||
break;
|
||||
case ApiErrorCode.NotAdmin:
|
||||
errorMessage = messages.adminerror;
|
||||
break;
|
||||
case ApiErrorCode.NoAdminUser:
|
||||
errorMessage = messages.noadminerror;
|
||||
break;
|
||||
default:
|
||||
errorMessage = messages.loginerror;
|
||||
break;
|
||||
}
|
||||
|
||||
toasts.addToast(
|
||||
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, values, setFieldValue, isSubmitting, isValid }) => (
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<div className="flex flex-col sm:flex-row sm:gap-4">
|
||||
<div className="w-full">
|
||||
<label htmlFor="hostname" className="text-label">
|
||||
{intl.formatMessage(
|
||||
messages.hostname,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
{values.useSsl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
type="text"
|
||||
className="rounded-r-only flex-1"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.hostname,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
<div className="error">{errors.hostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="port" className="text-label">
|
||||
{intl.formatMessage(messages.port)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0">
|
||||
<Field
|
||||
id="port"
|
||||
name="port"
|
||||
inputMode="numeric"
|
||||
type="text"
|
||||
className="short flex-1"
|
||||
placeholder={intl.formatMessage(messages.port)}
|
||||
/>
|
||||
{errors.port && touched.port && (
|
||||
<div className="error">{errors.port}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="useSsl" className="text-label mt-2">
|
||||
{intl.formatMessage(messages.enablessl)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="useSsl"
|
||||
name="useSsl"
|
||||
type="checkbox"
|
||||
onChange={() => {
|
||||
setFieldValue('useSsl', !values.useSsl);
|
||||
setFieldValue('port', values.useSsl ? 8096 : 443);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="urlBase" className="text-label mt-1">
|
||||
{intl.formatMessage(messages.urlBase)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="urlBase"
|
||||
name="urlBase"
|
||||
placeholder={intl.formatMessage(messages.urlBase)}
|
||||
/>
|
||||
</div>
|
||||
{errors.urlBase && touched.urlBase && (
|
||||
<div className="error">{errors.urlBase}</div>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-label inline-flex gap-1 align-middle"
|
||||
>
|
||||
{intl.formatMessage(messages.email)}
|
||||
<span className="label-tip">
|
||||
<Tooltip
|
||||
content={intl.formatMessage(
|
||||
messages.emailtooltip,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
>
|
||||
<span className="tooltip-trigger">
|
||||
<InformationCircleIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.email)}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && touched.email && (
|
||||
<div className="error">{errors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor="username" className="text-label">
|
||||
{intl.formatMessage(messages.username)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && touched.username && (
|
||||
<div className="error">{errors.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor="password" className="text-label">
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flexrounded-md shadow-sm">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && touched.password && (
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex flex-row-reverse justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</Button>
|
||||
</span>
|
||||
{onCancel && (
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button buttonType="default" onClick={() => onCancel()}>
|
||||
<FormattedMessage {...messages.back} />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
export default JellyfinSetup;
|
||||
@@ -1,4 +1,4 @@
|
||||
import PlexLoginButton from '@app/components/Login/PlexLoginButton';
|
||||
import PlexLoginButton from '@app/components/PlexLoginButton';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import PlexLoginButton from '@app/components/Login/PlexLoginButton';
|
||||
import JellyfinSetup from '@app/components/Setup/JellyfinSetup';
|
||||
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
|
||||
import PlexLoginButton from '@app/components/PlexLoginButton';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
@@ -83,9 +83,11 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
||||
</div>
|
||||
{serverType === MediaServerType.PLEX && (
|
||||
<>
|
||||
<div className="flex justify-center bg-black/30 px-10 py-8">
|
||||
<div
|
||||
className="px-10 py-8"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
<PlexLoginButton
|
||||
large
|
||||
onAuthToken={(authToken) => {
|
||||
setMediaServerType(MediaServerType.PLEX);
|
||||
setAuthToken(authToken);
|
||||
@@ -100,14 +102,16 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
|
||||
</>
|
||||
)}
|
||||
{serverType === MediaServerType.JELLYFIN && (
|
||||
<JellyfinSetup
|
||||
<JellyfinLogin
|
||||
initial={true}
|
||||
revalidate={revalidate}
|
||||
serverType={serverType}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)}
|
||||
{serverType === MediaServerType.EMBY && (
|
||||
<JellyfinSetup
|
||||
<JellyfinLogin
|
||||
initial={true}
|
||||
revalidate={revalidate}
|
||||
serverType={serverType}
|
||||
onCancel={onCancel}
|
||||
|
||||
@@ -187,7 +187,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
})
|
||||
) {
|
||||
mediaLinks.push({
|
||||
text: getAvalaibleMediaServerName(),
|
||||
text: getAvailableMediaServerName(),
|
||||
url: plexUrl,
|
||||
svg: <PlayIcon />,
|
||||
});
|
||||
@@ -201,7 +201,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
})
|
||||
) {
|
||||
mediaLinks.push({
|
||||
text: getAvalaible4kMediaServerName(),
|
||||
text: getAvailable4kMediaServerName(),
|
||||
url: plexUrl4k,
|
||||
svg: <PlayIcon />,
|
||||
});
|
||||
@@ -322,7 +322,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
(provider) => provider.iso_3166_1 === streamingRegion
|
||||
)?.flatrate ?? [];
|
||||
|
||||
function getAvalaibleMediaServerName() {
|
||||
function getAvailableMediaServerName() {
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||
}
|
||||
@@ -334,7 +334,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
|
||||
}
|
||||
|
||||
function getAvalaible4kMediaServerName() {
|
||||
function getAvailable4kMediaServerName() {
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ const defaultSettings = {
|
||||
applicationUrl: '',
|
||||
hideAvailable: false,
|
||||
localLogin: true,
|
||||
mediaServerLogin: true,
|
||||
movie4kEnabled: false,
|
||||
series4kEnabled: false,
|
||||
discoverRegion: '',
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import PlexOAuth from '@app/utils/plex';
|
||||
import { useState } from 'react';
|
||||
|
||||
const plexOAuth = new PlexOAuth();
|
||||
|
||||
function usePlexLogin({
|
||||
onAuthToken,
|
||||
onError,
|
||||
}: {
|
||||
onAuthToken: (authToken: string) => void;
|
||||
onError?: (err: string) => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getPlexLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const authToken = await plexOAuth.login();
|
||||
setLoading(false);
|
||||
onAuthToken(authToken);
|
||||
} catch (e) {
|
||||
if (onError) {
|
||||
onError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = () => {
|
||||
plexOAuth.preparePopup();
|
||||
setTimeout(() => getPlexLogin(), 1500);
|
||||
};
|
||||
|
||||
return { loading, login };
|
||||
}
|
||||
|
||||
export default usePlexLogin;
|
||||
@@ -246,9 +246,7 @@
|
||||
"components.Login.initialsigningin": "Connecting…",
|
||||
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
||||
"components.Login.loginerror": "Something went wrong while trying to sign in.",
|
||||
"components.Login.loginwithapp": "Login with {appName}",
|
||||
"components.Login.noadminerror": "No admin user found on the server.",
|
||||
"components.Login.orsigninwith": "Or sign in with",
|
||||
"components.Login.password": "Password",
|
||||
"components.Login.port": "Port",
|
||||
"components.Login.save": "Add",
|
||||
@@ -443,6 +441,8 @@
|
||||
"components.PersonDetails.birthdate": "Born {birthdate}",
|
||||
"components.PersonDetails.crewmember": "Crew",
|
||||
"components.PersonDetails.lifespan": "{birthdate} – {deathdate}",
|
||||
"components.PlexLoginButton.signingin": "Signing In…",
|
||||
"components.PlexLoginButton.signinwithplex": "Sign In",
|
||||
"components.QuotaSelector.days": "{count, plural, one {day} other {days}}",
|
||||
"components.QuotaSelector.movieRequests": "{quotaLimit} <quotaUnits>{movies} per {quotaDays} {days}</quotaUnits>",
|
||||
"components.QuotaSelector.movies": "{count, plural, one {movie} other {movies}}",
|
||||
@@ -915,16 +915,9 @@
|
||||
"components.Settings.SettingsMain.applicationurl": "Application URL",
|
||||
"components.Settings.SettingsMain.cacheImages": "Enable Image Caching",
|
||||
"components.Settings.SettingsMain.cacheImagesTip": "Cache externally sourced images (requires a significant amount of disk space)",
|
||||
"components.Settings.SettingsMain.csrfProtection": "Enable CSRF Protection",
|
||||
"components.Settings.SettingsMain.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
|
||||
"components.Settings.SettingsMain.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
|
||||
"components.Settings.SettingsMain.discoverRegion": "Discover Region",
|
||||
"components.Settings.SettingsMain.discoverRegionTip": "Filter content by regional availability",
|
||||
"components.Settings.SettingsMain.dnsServers": "Custom DNS Servers",
|
||||
"components.Settings.SettingsMain.dnsServersTip": "Comma-separated list of custom DNS servers, e.g. \"1.1.1.1,[2606:4700:4700::1111]\"",
|
||||
"components.Settings.SettingsMain.enableSpecialEpisodes": "Allow Special Episodes Requests",
|
||||
"components.Settings.SettingsMain.forceIpv4First": "IPv4 Resolution First",
|
||||
"components.Settings.SettingsMain.forceIpv4FirstTip": "Force Jellyseerr to resolve IPv4 addresses first instead of IPv6",
|
||||
"components.Settings.SettingsMain.general": "General",
|
||||
"components.Settings.SettingsMain.generalsettings": "General Settings",
|
||||
"components.Settings.SettingsMain.generalsettingsDescription": "Configure global and default settings for Jellyseerr.",
|
||||
@@ -933,36 +926,43 @@
|
||||
"components.Settings.SettingsMain.originallanguage": "Discover Language",
|
||||
"components.Settings.SettingsMain.originallanguageTip": "Filter content by original language",
|
||||
"components.Settings.SettingsMain.partialRequestsEnabled": "Allow Partial Series Requests",
|
||||
"components.Settings.SettingsMain.proxyBypassFilter": "Proxy Ignored Addresses",
|
||||
"components.Settings.SettingsMain.proxyBypassFilterTip": "Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
||||
"components.Settings.SettingsMain.proxyBypassLocalAddresses": "Bypass Proxy for Local Addresses",
|
||||
"components.Settings.SettingsMain.proxyEnabled": "HTTP(S) Proxy",
|
||||
"components.Settings.SettingsMain.proxyHostname": "Proxy Hostname",
|
||||
"components.Settings.SettingsMain.proxyPassword": "Proxy Password",
|
||||
"components.Settings.SettingsMain.proxyPort": "Proxy Port",
|
||||
"components.Settings.SettingsMain.proxySsl": "Use SSL For Proxy",
|
||||
"components.Settings.SettingsMain.proxyUser": "Proxy Username",
|
||||
"components.Settings.SettingsMain.streamingRegion": "Streaming Region",
|
||||
"components.Settings.SettingsMain.streamingRegionTip": "Show streaming sites by regional availability",
|
||||
"components.Settings.SettingsMain.toastApiKeyFailure": "Something went wrong while generating a new API key.",
|
||||
"components.Settings.SettingsMain.toastApiKeySuccess": "New API key generated successfully!",
|
||||
"components.Settings.SettingsMain.toastSettingsFailure": "Something went wrong while saving settings.",
|
||||
"components.Settings.SettingsMain.toastSettingsSuccess": "Settings saved successfully!",
|
||||
"components.Settings.SettingsMain.trustProxy": "Enable Proxy Support",
|
||||
"components.Settings.SettingsMain.trustProxyTip": "Allow Jellyseerr to correctly register client IP addresses behind a proxy",
|
||||
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
|
||||
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
|
||||
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||
"components.Settings.SettingsMain.validationProxyPort": "You must provide a valid port",
|
||||
"components.Settings.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.",
|
||||
"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.dnsServers": "Custom DNS Servers",
|
||||
"components.Settings.SettingsNetwork.dnsServersTip": "Comma-separated list of custom DNS servers, e.g. \"1.1.1.1,[2606:4700:4700::1111]\"",
|
||||
"components.Settings.SettingsNetwork.forceIpv4First": "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.networksettings": "Network Settings",
|
||||
"components.Settings.SettingsNetwork.networksettingsDescription": "Configure network settings for your Jellyseerr instance.",
|
||||
"components.Settings.SettingsNetwork.proxyBypassFilter": "Proxy Ignored Addresses",
|
||||
"components.Settings.SettingsNetwork.proxyBypassFilterTip": "Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
||||
"components.Settings.SettingsNetwork.proxyBypassLocalAddresses": "Bypass Proxy for Local Addresses",
|
||||
"components.Settings.SettingsNetwork.proxyEnabled": "HTTP(S) Proxy",
|
||||
"components.Settings.SettingsNetwork.proxyHostname": "Proxy Hostname",
|
||||
"components.Settings.SettingsNetwork.proxyPassword": "Proxy Password",
|
||||
"components.Settings.SettingsNetwork.proxyPort": "Proxy Port",
|
||||
"components.Settings.SettingsNetwork.proxySsl": "Use SSL For Proxy",
|
||||
"components.Settings.SettingsNetwork.proxyUser": "Proxy Username",
|
||||
"components.Settings.SettingsNetwork.toastSettingsFailure": "Something went wrong while saving settings.",
|
||||
"components.Settings.SettingsNetwork.toastSettingsSuccess": "Settings saved successfully!",
|
||||
"components.Settings.SettingsNetwork.trustProxy": "Enable Proxy Support",
|
||||
"components.Settings.SettingsNetwork.trustProxyTip": "Allow Jellyseerr to correctly register client IP addresses behind a proxy",
|
||||
"components.Settings.SettingsNetwork.validationProxyPort": "You must provide a valid port",
|
||||
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
|
||||
"components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users",
|
||||
"components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In",
|
||||
"components.Settings.SettingsUsers.localLoginTip": "Allow users to sign in using their email address and password",
|
||||
"components.Settings.SettingsUsers.loginMethods": "Login Methods",
|
||||
"components.Settings.SettingsUsers.loginMethodsTip": "Configure login methods for users.",
|
||||
"components.Settings.SettingsUsers.mediaServerLogin": "Enable {mediaServerName} Sign-In",
|
||||
"components.Settings.SettingsUsers.mediaServerLoginTip": "Allow users to sign in using their {mediaServerName} account",
|
||||
"components.Settings.SettingsUsers.localLoginTip": "Allow users to sign in using their email address and password, instead of {mediaServerName} OAuth",
|
||||
"components.Settings.SettingsUsers.movieRequestLimitLabel": "Global Movie Request Limit",
|
||||
"components.Settings.SettingsUsers.newPlexLogin": "Enable New {mediaServerName} Sign-In",
|
||||
"components.Settings.SettingsUsers.newPlexLoginTip": "Allow {mediaServerName} users to sign in without first being imported",
|
||||
@@ -1074,6 +1074,7 @@
|
||||
"components.Settings.menuJellyfinSettings": "{mediaServerName}",
|
||||
"components.Settings.menuJobs": "Jobs & Cache",
|
||||
"components.Settings.menuLogs": "Logs",
|
||||
"components.Settings.menuNetwork": "Network",
|
||||
"components.Settings.menuNotifications": "Notifications",
|
||||
"components.Settings.menuPlexSettings": "Plex",
|
||||
"components.Settings.menuServices": "Services",
|
||||
|
||||
@@ -194,7 +194,6 @@ CoreApp.getInitialProps = async (initialProps) => {
|
||||
movie4kEnabled: false,
|
||||
series4kEnabled: false,
|
||||
localLogin: true,
|
||||
mediaServerLogin: true,
|
||||
discoverRegion: '',
|
||||
streamingRegion: '',
|
||||
originalLanguage: '',
|
||||
|
||||
16
src/pages/settings/network.tsx
Normal file
16
src/pages/settings/network.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import SettingsLayout from '@app/components/Settings/SettingsLayout';
|
||||
import SettingsNetwork from '@app/components/Settings/SettingsNetwork';
|
||||
import useRouteGuard from '@app/hooks/useRouteGuard';
|
||||
import { Permission } from '@app/hooks/useUser';
|
||||
import type { NextPage } from 'next';
|
||||
|
||||
const SettingsNetworkPage: NextPage = () => {
|
||||
useRouteGuard(Permission.ADMIN);
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<SettingsNetwork />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsNetworkPage;
|
||||
@@ -74,6 +74,15 @@
|
||||
top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.plex-button {
|
||||
@apply flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-center text-sm font-medium text-white transition duration-150 ease-in-out disabled:opacity-50;
|
||||
background-color: #cc7b19;
|
||||
}
|
||||
|
||||
.plex-button:hover {
|
||||
background: #f19a30;
|
||||
}
|
||||
|
||||
.server-type-button {
|
||||
@apply rounded-md border border-gray-500 bg-gray-700 px-4 py-2 text-white transition duration-150 ease-in-out hover:bg-gray-500;
|
||||
}
|
||||
@@ -345,8 +354,9 @@
|
||||
@apply relative -ml-px inline-flex items-center border border-gray-500 bg-indigo-600 bg-opacity-80 px-3 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out last:rounded-r-md hover:bg-opacity-100 active:bg-gray-100 active:text-gray-700 sm:px-3.5;
|
||||
}
|
||||
|
||||
.button-md :where(svg),
|
||||
button.input-action svg {
|
||||
.button-md svg,
|
||||
button.input-action svg,
|
||||
.plex-button svg {
|
||||
@apply ml-2 mr-2 h-5 w-5 first:ml-0 last:mr-0;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user