feat: add openid connect settings interface
This commit is contained in:
@@ -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
57
public/images/openid.svg
Normal 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 |
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
373
src/components/Settings/EditOidcModal/index.tsx
Normal file
373
src/components/Settings/EditOidcModal/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/components/Settings/SettingsOidc/index.tsx
Normal file
167
src/components/Settings/SettingsOidc/index.tsx
Normal 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 }));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user