feat: add openid connect settings interface

This commit is contained in:
Michael Thomas
2025-01-21 08:37:41 -05:00
parent 51849cd9de
commit 03d869ca59
9 changed files with 818 additions and 14 deletions

View File

@@ -254,6 +254,41 @@ components:
enableSpecialEpisodes:
type: boolean
example: false
OidcProvider:
type: object
properties:
slug:
type: string
readOnly: true
name:
type: string
issuerUrl:
type: string
clientId:
type: string
clientSecret:
type: string
logo:
type: string
requiredClaims:
type: string
scopes:
type: string
newUserLogin:
type: boolean
required:
- slug
- name
- issuerUrl
- clientId
- clientSecret
OidcSettings:
type: object
properties:
providers:
type: array
items:
$ref: '#/components/schemas/OidcProvider'
NetworkSettings:
type: object
properties:
@@ -2212,6 +2247,64 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
/settings/oidc:
get:
summary: Get OpenID Connect settings
description: Retrieves all OpenID Connect settings in a JSON object.
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/OidcSettings'
/settings/oidc/{provider}:
put:
summary: Update OpenID Connect provider
description: Updates an existing OpenID Connect provider with the provided values.
tags:
- settings
parameters:
- in: path
name: provider
required: true
schema:
type: string
description: Provider slug
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OidcProvider'
responses:
'200':
description: 'Radarr instance updated'
content:
application/json:
schema:
$ref: '#/components/schemas/RadarrSettings'
delete:
summary: Delete OpenID Connect provider
description: Deletes an existing OpenID Connect provider based on the provider slug parameter.
tags:
- settings
parameters:
- in: path
name: provider
required: true
schema:
type: string
description: Provider slug
responses:
'200':
description: 'OpenID Connect provider deleted'
content:
application/json:
schema:
$ref: '#/components/schemas/OidcSettings'
/settings/network:
get:
summary: Get network settings

57
public/images/openid.svg Normal file
View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.0"
width="120"
height="120"
id="svg2593"
xml:space="preserve"
sodipodi:docname="OpenID_logo_2.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showgrid="false"
inkscape:zoom="5.3731101"
inkscape:cx="69.60587"
inkscape:cy="65.883631"
inkscape:window-width="1512"
inkscape:window-height="945"
inkscape:window-x="0"
inkscape:window-y="37"
inkscape:window-maximized="0"
inkscape:current-layer="svg2593" /><defs
id="defs2596"><clipPath
id="clipPath2616"><path
d="M 0,14400 H 14400 V 0 H 0 Z"
id="path2618" /></clipPath></defs><g
transform="matrix(1.25,0,0,-1.25,-8601.6804,9121.1624)"
id="g2602"><g
transform="matrix(0.375,0,0,0.375,4301.4506,4557.5812)"
id="g2734"><g
id="g2726"><g
transform="translate(6998.0969,7259.1135)"
id="g2604"><path
d="M 0,0 V -159.939 -180 l 32,15.061 V 15.633 Z"
id="path2606"
style="fill:#f8931e;fill-opacity:1;fill-rule:nonzero;stroke:none" /></g><g
transform="translate(7108.9192,7206.3137)"
id="g2608"><path
d="M 0,0 4.417,-45.864 -57.466,-32.4"
id="path2610"
style="fill:#b3b3b3;fill-opacity:1;fill-rule:nonzero;stroke:none" /></g><g
transform="translate(6934.0969,7147.6213)"
id="g2620"><path
d="M 0,0 C 0,22.674 24.707,41.769 58.383,47.598 V 67.923 C 6.873,61.697 -32,33.656 -32,0 -32,-34.869 9.725,-63.709 64,-68.508 v 20.061 C 27.484,-43.869 0,-23.919 0,0 M 101.617,67.915 V 47.598 c 13.399,-2.319 25.385,-6.727 34.951,-12.64 l 22.627,13.984 c -15.42,9.531 -35.322,16.283 -57.578,18.973"
id="path2622"
style="fill:#b3b3b3;fill-opacity:1;fill-rule:nonzero;stroke:none" /></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -109,6 +109,45 @@ settingsRoutes.post('/main/regenerate', async (req, res, next) => {
return res.status(200).json(filteredMainSettings(req.user, main));
});
settingsRoutes.get('/oidc', async (req, res) => {
const settings = getSettings();
return res.status(200).json(settings.oidc);
});
settingsRoutes.put('/oidc/:slug', async (req, res) => {
const settings = getSettings();
let provider = settings.oidc.providers.findIndex(
(p) => p.slug === req.params.slug
);
if (provider !== -1) {
Object.assign(settings.oidc.providers[provider], req.body);
} else {
settings.oidc.providers.push({ slug: req.params.slug, ...req.body });
provider = settings.oidc.providers.length - 1;
}
await settings.save();
return res.status(200).json(settings.oidc.providers[provider]);
});
settingsRoutes.delete('/oidc/:slug', async (req, res) => {
const settings = getSettings();
const provider = settings.oidc.providers.findIndex(
(p) => p.slug === req.params.slug
);
if (provider === -1)
return res.status(404).json({ message: 'Provider not found' });
settings.oidc.providers.splice(provider, 1);
await settings.save();
return res.status(200).json(settings.oidc);
});
settingsRoutes.get('/plex', (_req, res) => {
const settings = getSettings();

View File

@@ -1,31 +1,34 @@
import { Field } from 'formik';
import { useId } from 'react';
import { twMerge } from 'tailwind-merge';
interface LabeledCheckboxProps {
id: string;
name: string;
className?: string;
label: string;
description: string;
onChange: () => void;
onChange?: () => void;
children?: React.ReactNode;
}
const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
id,
name,
className,
label,
description,
onChange,
children,
}) => {
const id = useId();
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} />
<Field type="checkbox" id={id} name={name} onChange={onChange} />
</div>
<div className="ml-3 text-sm leading-6">
<label htmlFor="localLogin" className="block">
<label htmlFor={id} className="block">
<div className="flex flex-col">
<span className="font-medium text-white">{label}</span>
<span className="font-normal text-gray-400">{description}</span>

View File

@@ -0,0 +1,373 @@
import Accordion from '@app/components/Common/Accordion';
import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { ChevronRightIcon } from '@heroicons/react/20/solid';
import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
import type { OidcProvider } from '@server/lib/settings';
import {
ErrorMessage,
Field,
Formik,
useFormikContext,
type FieldAttributes,
} from 'formik';
import { useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
const messages = defineMessages('settings.settings.SettingsOidc', {
required: '{field} is required',
url: '{field} must be a valid URL',
addoidc: 'Add New OpenID Connect Provider',
editoidc: 'Edit {name}',
oidcDomain: 'Issuer URL',
oidcDomainTip:
"The base URL of the identity provider's OpenID Connect endpoint",
oidcSlug: 'Provider Slug',
oidcSlugTip: 'Unique identifier for the provider',
oidcName: 'Provider Name',
oidcNameTip: 'Name of the provider which appears on the login screen',
oidcClientId: 'Client ID',
oidcClientIdTip: 'The Client ID assigned to Jellyseerr',
oidcClientSecret: 'Client Secret',
oidcClientSecretTip: 'The Client Secret assigned to Jellyseerr',
oidcLogo: 'Logo',
oidcLogoTip:
'The logo to display for the provider. Should be a URL or base64 encoded image',
oidcScopes: 'Scopes',
oidcScopesTip: 'Comma-separated list of scopes to request from the provider',
oidcRequiredClaims: 'Required Claims',
oidcRequiredClaimsTip:
'Comma-separated list of boolean claims that are required to log in',
oidcNewUserLogin: 'Allow New Users',
oidcNewUserLoginTip:
'Create accounts for new users logging in with this provider',
saveSuccess: 'OpenID Connect provider saved successfully!',
saveError: 'Failed to save OpenID Connect provider configuration',
});
interface EditOidcModalProps {
show: boolean;
provider?: OidcProvider;
onClose: () => void;
onOk: () => void;
}
function SlugField(props: FieldAttributes<unknown>) {
const {
values: { name },
setFieldValue,
} = useFormikContext<Partial<OidcProvider>>();
useEffect(() => {
setFieldValue(props.name, name?.toLowerCase().replace(/\s/g, '-'));
}, [props.name, name, setFieldValue]);
return <Field {...props} />;
}
export default function EditOidcModal(props: EditOidcModalProps) {
const intl = useIntl();
const { addToast } = useToasts();
const errorMessage = (
field: keyof typeof messages,
message: keyof typeof messages = 'required'
) =>
intl.formatMessage(messages[message], {
field: intl.formatMessage(messages[field]),
});
const oidcSettingsSchema = Yup.object().shape({
slug: Yup.string().required(errorMessage('oidcSlug')),
name: Yup.string().required(errorMessage('oidcName')),
issuerUrl: Yup.string()
.url(errorMessage('oidcDomain', 'url'))
.required(errorMessage('oidcDomain')),
clientId: Yup.string().required(errorMessage('oidcClientId')),
clientSecret: Yup.string().required(errorMessage('oidcClientSecret')),
logo: Yup.string(),
requiredClaims: Yup.string(),
scopes: Yup.string(),
newUserLogin: Yup.boolean(),
});
const onSubmit = async ({ slug, ...provider }: OidcProvider) => {
try {
const res = await fetch(`/api/v1/settings/oidc/${slug}`, {
method: 'PUT',
body: JSON.stringify(provider),
headers: {
'Content-Type': 'application/json',
},
});
if (res.status === 200) {
addToast(intl.formatMessage(messages.saveSuccess), {
appearance: 'success',
autoDismiss: true,
});
props.onOk();
} else {
throw new Error(`Request failed with code ${res.status}`);
}
} catch (e) {
addToast(intl.formatMessage(messages.saveError), {
appearance: 'error',
autoDismiss: true,
});
}
};
return (
<Transition show={props.show}>
<Formik
initialValues={{
slug: props.provider?.slug ?? '',
name: props.provider?.name ?? '',
issuerUrl: props.provider?.issuerUrl ?? '',
clientId: props.provider?.clientId ?? '',
clientSecret: props.provider?.clientSecret ?? '',
logo: props.provider?.logo,
requiredClaims: props.provider?.requiredClaims,
scopes: props.provider?.scopes,
newUserLogin: props.provider?.newUserLogin,
}}
validationSchema={oidcSettingsSchema}
onSubmit={onSubmit}
>
{({ handleSubmit, isValid }) => (
<Modal
onCancel={props.onClose}
cancelButtonProps={{ type: 'button' }}
okButtonType="primary"
okButtonProps={{ type: 'button' }}
okDisabled={!isValid}
onOk={() => handleSubmit()}
okText={intl.formatMessage(globalMessages.save)}
title={
props.provider
? intl.formatMessage(messages.editoidc, {
name: props.provider.name,
})
: intl.formatMessage(messages.addoidc)
}
>
<div className="form-row">
<label htmlFor="oidcName" className="text-label">
{intl.formatMessage(messages.oidcName)}
<span className="label-required">*</span>
<span className="label-tip">
{intl.formatMessage(messages.oidcNameTip)}
</span>
</label>
<div className="form-input-area">
<Field id="oidcName" name="name" type="text" />
<ErrorMessage className="error" component="span" name="name" />
</div>
</div>
<div className="form-row">
<label htmlFor="oidcLogo" className="text-label">
{intl.formatMessage(messages.oidcLogo)}
<span className="label-tip">
{intl.formatMessage(messages.oidcLogoTip)}
</span>
</label>
<div className="form-input-area">
<div className="relative">
<Field
id="oidcLogo"
name="logo"
type="text"
className="pr-10"
/>
<a
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 transition-colors hover:text-gray-200"
href="https://selfh.st/icons"
target="_blank"
rel="noreferrer noopener"
>
<MagnifyingGlassIcon className="h-4 w-4" />
</a>
</div>
<ErrorMessage className="error" component="span" name="logo" />
</div>
</div>
<div className="form-row">
<label htmlFor="oidcDomain" className="text-label">
{intl.formatMessage(messages.oidcDomain)}
<span className="label-required">*</span>
<span className="label-tip">
{intl.formatMessage(messages.oidcDomainTip)}
</span>
</label>
<div className="form-input-area">
<Field id="oidcDomain" name="issuerUrl" type="text" />
<ErrorMessage
className="error"
component="span"
name="issuerUrl"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="oidcClientId" className="text-label">
{intl.formatMessage(messages.oidcClientId)}
<span className="label-required">*</span>
<span className="label-tip">
{intl.formatMessage(messages.oidcClientIdTip)}
</span>
</label>
<div className="form-input-area">
<Field id="oidcClientId" name="clientId" type="text" />
<ErrorMessage
className="error"
component="span"
name="clientId"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="oidcClientSecret" className="text-label">
{intl.formatMessage(messages.oidcClientSecret)}
<span className="label-required">*</span>
<span className="label-tip">
{intl.formatMessage(messages.oidcClientSecretTip)}
</span>
</label>
<div className="form-input-area">
<div className="flex">
<SensitiveInput
id="oidcClientSecret"
name="clientSecret"
as="field"
autoComplete="new-password"
/>
</div>
<ErrorMessage
className="error"
component="span"
name="clientSecret"
/>
</div>
</div>
{/* Advanced Settings */}
<Accordion>
{({ openIndexes, AccordionContent, handleClick }) => (
<>
<button
type="button"
onClick={() => handleClick(0)}
className="flex w-full items-center gap-0.5 py-4 font-bold text-gray-400"
>
<ChevronRightIcon
width={18}
className={twMerge(
'transition-transform',
openIndexes.includes(0) ? 'rotate-90' : ''
)}
/>
Advanced Settings
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div className="form-row mt-0">
<label htmlFor="oidcSlug" className="text-label">
{intl.formatMessage(messages.oidcSlug)}
<span className="label-required">*</span>
<span className="label-tip">
{intl.formatMessage(messages.oidcSlugTip)}
</span>
</label>
<div className="form-input-area">
<SlugField
id="oidcSlug"
name="slug"
type="text"
// prevent editing of slug if editing an existing provider,
// to avoid invalidating existing linked accounts
readOnly={props.provider != null}
disabled={props.provider != null}
className={props.provider != null ? 'opacity-50' : ''}
/>
<ErrorMessage
className="error"
component="span"
name="slug"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="oidcScopes" className="text-label">
{intl.formatMessage(messages.oidcScopes)}
<span className="label-tip">
{intl.formatMessage(messages.oidcScopesTip)}
</span>
</label>
<div className="form-input-area">
<Field id="oidcScopes" name="scopes" type="text" />
<ErrorMessage
className="error"
component="span"
name="scopes"
/>
</div>
</div>
<div className="form-row">
<label
htmlFor="oidcRequiredClaims"
className="text-label"
>
{intl.formatMessage(messages.oidcRequiredClaims)}
<span className="label-tip">
{intl.formatMessage(messages.oidcRequiredClaimsTip)}
</span>
</label>
<div className="form-input-area">
<Field
id="oidcRequiredClaims"
name="requiredClaims"
type="text"
/>
<ErrorMessage
className="error"
component="span"
name="requiredClaims"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="oidcNewUserLogin" className="text-label">
{intl.formatMessage(messages.oidcNewUserLogin)}
<span className="label-tip">
{intl.formatMessage(messages.oidcNewUserLoginTip)}
</span>
</label>
<div className="form-input-area">
<Field
id="oidcNewUserLogin"
name="newUserLogin"
type="checkbox"
/>
<ErrorMessage
className="error"
component="span"
name="newUserLogin"
/>
</div>
</div>
</AccordionContent>
</>
)}
</Accordion>
</Modal>
)}
</Formik>
</Transition>
);
}

View File

@@ -0,0 +1,167 @@
import Button from '@app/components/Common/Button';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import Modal from '@app/components/Common/Modal';
import EditOidcModal from '@app/components/Settings/EditOidcModal';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { PlusIcon } from '@heroicons/react/24/outline';
import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid';
import type { OidcProvider, OidcSettings } from '@server/lib/settings';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.Settings.SettingsOidc', {
configureoidc: 'Configure OpenID Connect',
addOidcProvider: 'Add OpenID Connect Provider',
oidcMatchUsername: 'Allow {mediaServerName} Usernames',
oidcMatchUsernameTip:
'Match OIDC users with their {mediaServerName} accounts by username',
oidcAutomaticLogin: 'Automatic Login',
oidcAutomaticLoginTip:
'Automatically navigate to the OIDC login and logout pages. This functionality ' +
'only supported when OIDC is the exclusive login method.',
deleteError: 'Failed to delete OpenID Connect provider',
});
interface SettingsOidcProps {
show: boolean;
onOk?: () => void;
}
export default function SettingsOidc(props: SettingsOidcProps) {
const { addToast } = useToasts();
const intl = useIntl();
const [editOidcModal, setEditOidcModal] = useState<{
open: boolean;
provider?: OidcProvider;
}>({
open: false,
provider: undefined,
});
const { data, mutate: revalidate } = useSWR<OidcSettings>(
'/api/v1/settings/oidc'
);
async function onDelete(provider: OidcProvider) {
try {
const response = await fetch(`/api/v1/settings/oidc/${provider.slug}`, {
method: 'DELETE',
});
if (response.status !== 200)
throw new Error(`Request failed with status ${response.status}`);
revalidate(await response.json());
} catch (e) {
addToast(intl.formatMessage(messages.deleteError), {
autoDismiss: true,
appearance: 'error',
});
}
}
return (
<>
<Transition show={props.show}>
<Modal
okText={intl.formatMessage(globalMessages.close)}
onOk={props.onOk}
okButtonProps={{ type: 'button' }}
title={intl.formatMessage(messages.configureoidc)}
backgroundClickable={false}
>
<ul className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{data?.providers.map((provider) => (
<li
className="col-span-1 flex flex-col justify-between rounded-lg bg-gray-700 shadow ring-1 ring-gray-500"
key={provider.slug}
>
<div className="jusfity-between flex w-full items-center space-x-6 p-6">
<div className="flex-1 truncate">
<div className="mb-2 flex items-center space-x-2">
<h3 className="truncate text-lg font-bold leading-5 text-white">
{provider.name}
</h3>
</div>
<p className="mt-1 truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">Issuer URL</span>
{provider.issuerUrl}
</p>
<p className="mt-1 truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">Client ID</span>
{provider.clientId}
</p>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={provider.logo || '/images/openid.svg'}
alt={provider.name}
className="h-10 w-10 flex-shrink-0"
/>
</div>
<div className="border-t border-gray-500">
<div className="-mt-px flex">
<div className="flex w-0 flex-1 border-r border-gray-500">
<button
type="button"
onClick={() =>
setEditOidcModal({ open: true, provider })
}
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<PencilIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</button>
</div>
<div className="-ml-px flex w-0 flex-1">
<ConfirmButton
onClick={() => onDelete(provider)}
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-none rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
confirmText={intl.formatMessage(
globalMessages.areyousure
)}
>
<TrashIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.delete)}</span>
</ConfirmButton>
</div>
</div>
</div>
</li>
))}
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full items-center justify-center">
<Button
type="button"
buttonType="ghost"
className="mt-3 mb-3"
onClick={() => setEditOidcModal({ open: true })}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addOidcProvider)}</span>
</Button>
</div>
</li>
</ul>
</Modal>
</Transition>
<EditOidcModal
show={editOidcModal.open}
provider={editOidcModal.provider}
onClose={() => setEditOidcModal((prev) => ({ ...prev, open: false }))}
onOk={() => {
revalidate();
// preserve the provider so that it doesn't disappear while the dialog is closing
setEditOidcModal((prev) => ({ ...prev, open: false }));
}}
/>
</>
);
}

View File

@@ -4,14 +4,16 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import PermissionEdit from '@app/components/PermissionEdit';
import QuotaSelector from '@app/components/QuotaSelector';
import SettingsOidc from '@app/components/Settings/SettingsOidc';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ArrowDownOnSquareIcon, CogIcon } from '@heroicons/react/24/outline';
import { MediaServerType } from '@server/constants/server';
import type { MainSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
@@ -31,6 +33,9 @@ const messages = defineMessages('components.Settings.SettingsUsers', {
mediaServerLogin: 'Enable {mediaServerName} Sign-In',
mediaServerLoginTip:
'Allow users to sign in using their {mediaServerName} account',
oidcLogin: 'Enable OpenID Connect Sign-In',
oidcLoginTip:
'Allow users to sign in using OpenID Connect identity providers',
atLeastOneAuth: 'At least one authentication method must be selected.',
newPlexLogin: 'Enable New {mediaServerName} Sign-In',
newPlexLoginTip:
@@ -50,23 +55,25 @@ const SettingsUsers = () => {
mutate: revalidate,
} = useSWR<MainSettings>('/api/v1/settings/main');
const settings = useSettings();
const [showOidcDialog, setShowOidcDialog] = useState(false);
const schema = yup
.object()
.shape({
localLogin: yup.boolean(),
mediaServerLogin: yup.boolean(),
oidcLogin: yup.boolean(),
})
.test({
name: 'atLeastOneAuth',
test: function (values) {
const isValid = ['localLogin', 'mediaServerLogin'].some(
const isValid = ['localLogin', 'mediaServerLogin', 'oidcLogin'].some(
(field) => !!values[field]
);
if (isValid) return true;
return this.createError({
path: 'localLogin | mediaServerLogin',
path: 'localLogin | mediaServerLogin | oidcLogin',
message: intl.formatMessage(messages.atLeastOneAuth),
});
},
@@ -106,6 +113,7 @@ const SettingsUsers = () => {
initialValues={{
localLogin: data?.localLogin,
mediaServerLogin: data?.mediaServerLogin,
oidcLogin: data?.oidcLogin,
newPlexLogin: data?.newPlexLogin,
movieQuotaLimit: data?.defaultQuotas.movie.quotaLimit ?? 0,
movieQuotaDays: data?.defaultQuotas.movie.quotaDays ?? 7,
@@ -120,6 +128,7 @@ const SettingsUsers = () => {
await axios.post('/api/v1/settings/main', {
localLogin: values.localLogin,
mediaServerLogin: values.mediaServerLogin,
oidcLogin: values.oidcLogin,
newPlexLogin: values.newPlexLogin,
defaultQuotas: {
movie: {
@@ -163,16 +172,21 @@ const SettingsUsers = () => {
<span className="label-tip">
{intl.formatMessage(messages.loginMethodsTip)}
</span>
{'localLogin | mediaServerLogin' in errors && (
{'localLogin | mediaServerLogin | oidcLogin' in
errors && (
<span className="error">
{errors['localLogin | mediaServerLogin'] as string}
{
errors[
'localLogin | mediaServerLogin | oidcLogin'
] as string
}
</span>
)}
</span>
<div className="form-input-area max-w-lg">
<LabeledCheckbox
id="localLogin"
name="localLogin"
label={intl.formatMessage(messages.localLogin)}
description={intl.formatMessage(
messages.localLoginTip,
@@ -183,7 +197,7 @@ const SettingsUsers = () => {
}
/>
<LabeledCheckbox
id="mediaServerLogin"
name="mediaServerLogin"
className="mt-4"
label={intl.formatMessage(
messages.mediaServerLogin,
@@ -200,10 +214,41 @@ const SettingsUsers = () => {
)
}
/>
<div className="mt-4 flex justify-between">
<LabeledCheckbox
name="oidcLogin"
label={intl.formatMessage(
messages.oidcLogin,
mediaServerFormatValues
)}
description={intl.formatMessage(
messages.oidcLoginTip,
mediaServerFormatValues
)}
onChange={() => {
const newValue = !values.oidcLogin;
setFieldValue('oidcLogin', newValue);
if (newValue) setShowOidcDialog(true);
}}
/>
{values.oidcLogin && (
<CogIcon
className="w-8 cursor-pointer text-gray-400"
onClick={() => setShowOidcDialog(true)}
/>
)}
</div>
</div>
</div>
</div>
{values.oidcLogin && (
<SettingsOidc
show={showOidcDialog}
onOk={() => setShowOidcDialog(false)}
/>
)}
<div className="form-row">
<label htmlFor="newPlexLogin" className="checkbox-label">
{intl.formatMessage(

View File

@@ -256,7 +256,7 @@ const UserLinkedAccountsSettings = () => {
) : acct.type === LinkedAccountType.OpenIdConnect ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={acct.provider.logo ?? ''}
src={acct.provider.logo ?? '/images/openid.svg'}
alt={acct.provider.name}
/>
) : null}

View File

@@ -240,6 +240,7 @@
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
"components.Login.adminerror": "You must use an admin account to sign in.",
"components.Login.back": "Go back",
"components.Login.backtologin": "Back to Login",
"components.Login.credentialerror": "The username or password is incorrect.",
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
"components.Login.email": "Email Address",
@@ -1033,6 +1034,8 @@
"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",
"components.Settings.SettingsUsers.oidcLogin": "Enable OpenID Connect Sign-In",
"components.Settings.SettingsUsers.oidcLoginTip": "Allow users to sign in using OpenID Connect identity providers",
"components.Settings.SettingsUsers.toastSettingsFailure": "Something went wrong while saving settings.",
"components.Settings.SettingsUsers.toastSettingsSuccess": "User settings saved successfully!",
"components.Settings.SettingsUsers.tvRequestLimitLabel": "Global Series Request Limit",
@@ -1600,5 +1603,29 @@
"pages.pagenotfound": "Page Not Found",
"pages.returnHome": "Return Home",
"pages.serviceunavailable": "Service Unavailable",
"pages.somethingwentwrong": "Something Went Wrong"
"pages.somethingwentwrong": "Something Went Wrong",
"settings.settings.SettingsOidc.addoidc": "Add New OpenID Connect Provider",
"settings.settings.SettingsOidc.editoidc": "Edit {name}",
"settings.settings.SettingsOidc.oidcClientId": "Client ID",
"settings.settings.SettingsOidc.oidcClientIdTip": "The Client ID assigned to Jellyseerr",
"settings.settings.SettingsOidc.oidcClientSecret": "Client Secret",
"settings.settings.SettingsOidc.oidcClientSecretTip": "The Client Secret assigned to Jellyseerr",
"settings.settings.SettingsOidc.oidcDomain": "Issuer URL",
"settings.settings.SettingsOidc.oidcDomainTip": "The base URL of the identity provider's OpenID Connect endpoint",
"settings.settings.SettingsOidc.oidcLogo": "Logo",
"settings.settings.SettingsOidc.oidcLogoTip": "The logo to display for the provider. Should be a URL or base64 encoded image",
"settings.settings.SettingsOidc.oidcName": "Provider Name",
"settings.settings.SettingsOidc.oidcNameTip": "Name of the provider which appears on the login screen",
"settings.settings.SettingsOidc.oidcNewUserLogin": "Allow New Users",
"settings.settings.SettingsOidc.oidcNewUserLoginTip": "Create accounts for new users logging in with this provider",
"settings.settings.SettingsOidc.oidcRequiredClaims": "Required Claims",
"settings.settings.SettingsOidc.oidcRequiredClaimsTip": "Comma-separated list of boolean claims that are required to log in",
"settings.settings.SettingsOidc.oidcScopes": "Scopes",
"settings.settings.SettingsOidc.oidcScopesTip": "Comma-separated list of scopes to request from the provider",
"settings.settings.SettingsOidc.oidcSlug": "Provider Slug",
"settings.settings.SettingsOidc.oidcSlugTip": "Unique identifier for the provider",
"settings.settings.SettingsOidc.required": "{field} is required",
"settings.settings.SettingsOidc.saveError": "Failed to save OpenID Connect provider configuration",
"settings.settings.SettingsOidc.saveSuccess": "OpenID Connect provider saved successfully!",
"settings.settings.SettingsOidc.url": "{field} must be a valid URL"
}