mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Changed documentation for scopes and required claims lists to correctly reflect source code showing space-separated instead of comma-separated.
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
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 axios from 'axios';
|
|
import { 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: 'Space-separated list of scopes to request from the provider',
|
|
oidcRequiredClaims: 'Required Claims',
|
|
oidcRequiredClaimsTip:
|
|
'Space-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> & { readOnly?: boolean }) {
|
|
const {
|
|
values: { name },
|
|
setFieldValue,
|
|
} = useFormikContext<Partial<OidcProvider>>();
|
|
|
|
useEffect(() => {
|
|
if (!props.readOnly)
|
|
setFieldValue(props.name, name?.toLowerCase().replace(/\s/g, '-'));
|
|
}, [props.name, props.readOnly, 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 {
|
|
await axios.put(`/api/v1/settings/oidc/${slug}`, provider);
|
|
|
|
addToast(intl.formatMessage(messages.saveSuccess), {
|
|
appearance: 'success',
|
|
autoDismiss: true,
|
|
});
|
|
|
|
props.onOk();
|
|
} 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}
|
|
enableReinitialize
|
|
>
|
|
{({ handleSubmit, isValid, errors, touched }) => (
|
|
<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" />
|
|
{errors.name &&
|
|
touched.name &&
|
|
typeof errors.name === 'string' && (
|
|
<div className="error">{errors.name}</div>
|
|
)}
|
|
</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>
|
|
{errors.logo &&
|
|
touched.logo &&
|
|
typeof errors.logo === 'string' && (
|
|
<div className="error">{errors.logo}</div>
|
|
)}
|
|
</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" />
|
|
{errors.issuerUrl &&
|
|
touched.issuerUrl &&
|
|
typeof errors.issuerUrl === 'string' && (
|
|
<div className="error">{errors.issuerUrl}</div>
|
|
)}
|
|
</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" />
|
|
{errors.clientId &&
|
|
touched.clientId &&
|
|
typeof errors.clientId === 'string' && (
|
|
<div className="error">{errors.clientId}</div>
|
|
)}
|
|
</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>
|
|
{errors.clientSecret &&
|
|
touched.clientSecret &&
|
|
typeof errors.clientSecret === 'string' && (
|
|
<div className="error">{errors.clientSecret}</div>
|
|
)}
|
|
</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"
|
|
readOnly={props.provider != null}
|
|
disabled={props.provider != null}
|
|
className={props.provider != null ? 'opacity-50' : ''}
|
|
/>
|
|
{errors.slug &&
|
|
touched.slug &&
|
|
typeof errors.slug === 'string' && (
|
|
<div className="error">{errors.slug}</div>
|
|
)}
|
|
</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" />
|
|
{errors.scopes &&
|
|
touched.scopes &&
|
|
typeof errors.scopes === 'string' && (
|
|
<div className="error">{errors.scopes}</div>
|
|
)}
|
|
</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"
|
|
/>
|
|
{errors.requiredClaims &&
|
|
touched.requiredClaims &&
|
|
typeof errors.requiredClaims === 'string' && (
|
|
<div className="error">{errors.requiredClaims}</div>
|
|
)}
|
|
</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"
|
|
/>
|
|
{errors.newUserLogin &&
|
|
touched.newUserLogin &&
|
|
typeof errors.newUserLogin === 'string' && (
|
|
<div className="error">{errors.newUserLogin}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</AccordionContent>
|
|
</>
|
|
)}
|
|
</Accordion>
|
|
</Modal>
|
|
)}
|
|
</Formik>
|
|
</Transition>
|
|
);
|
|
}
|