From 68952a9239fbcd0fb809972f6dcab682ed1bc67c Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Sat, 25 May 2024 05:44:05 +0500 Subject: [PATCH 01/18] refactor(jellyfinsettings): abstract jellyfin hostname, updated ui to reflect it, better validation This PR refactors and abstracts jellyfin hostname into, jellyfin ip, jellyfin port, jellyfin useSsl, and jellyfin urlBase. This makes it more consistent with how plex settings are stored as well. In addition, this improves validation as validation can be applied seperately to them instead of as one whole regex doing the work to validate the url. UI was updated to reflect this. BREAKING CHANGE: Jellyfin settings now does not include a hostname. Instead it abstracted it to ip, port, useSsl, and urlBase. However, migration of old settings to new settings should work automatically. --- server/api/jellyfin.ts | 18 +- server/constants/error.ts | 3 + server/entity/Media.ts | 17 +- server/lib/availabilitySync.ts | 4 +- server/lib/scanners/jellyfin/index.ts | 5 +- server/lib/settings.ts | 69 ++++++- server/routes/auth.ts | 40 ++-- server/routes/settings/index.ts | 66 +++++-- server/routes/user/index.ts | 11 +- server/utils/getHostname.ts | 18 ++ src/components/Login/JellyfinLogin.tsx | 157 ++++++++++++++-- src/components/Settings/SettingsJellyfin.tsx | 184 +++++++++++++++++-- 12 files changed, 506 insertions(+), 86 deletions(-) create mode 100644 server/utils/getHostname.ts diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index bab7ea725..163fbcdc6 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -98,8 +98,9 @@ class JellyfinAPI { private jellyfinHost: string; private axios: AxiosInstance; - constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { - this.jellyfinHost = jellyfinHost; + constructor(hostname: string, authToken?: string, deviceId?: string) { + this.jellyfinHost = hostname; + this.authToken = authToken; let authHeaderVal = ''; @@ -175,6 +176,18 @@ class JellyfinAPI { return; } + public async getSystemInfo(): Promise { + try { + // TODO: remove axios from here + const systemInfoResponse = await this.axios.get('/System/Info'); + + return systemInfoResponse; + } catch (e) { + //TODO: Use the api error codes + throw new Error('Invalid auth token'); + } + } + public async getServerName(): Promise { try { const account = await this.axios.get( @@ -220,6 +233,7 @@ class JellyfinAPI { public async getLibraries(): Promise { try { + console.log('getting libraries with url', this.jellyfinHost); const mediaFolders = await this.axios.get(`/Library/MediaFolders`); return this.mapLibraries(mediaFolders.data.Items); diff --git a/server/constants/error.ts b/server/constants/error.ts index 87e37e4c2..2f06d52ae 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -2,4 +2,7 @@ export enum ApiErrorCode { InvalidUrl = 'INVALID_URL', InvalidCredentials = 'INVALID_CREDENTIALS', NotAdmin = 'NOT_ADMIN', + SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', + SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', + Generic = 'GENERIC', } diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 1932670e4..ce8892c9f 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -9,6 +9,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { getHostname } from '@server/utils/getHostname'; import { AfterLoad, Column, @@ -211,15 +212,19 @@ class Media { } else { const pageName = process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details'; - const { serverId, hostname, externalHostname } = getSettings().jellyfin; - let jellyfinHost = + const { serverId, externalHostname } = getSettings().jellyfin; + // let jellyfinHost = + // externalHostname && externalHostname.length > 0 + // ? externalHostname + // : hostname; + const jellyfinHost = externalHostname && externalHostname.length > 0 ? externalHostname - : hostname; + : getHostname(); - jellyfinHost = jellyfinHost.endsWith('/') - ? jellyfinHost.slice(0, -1) - : jellyfinHost; + // jellyfinHost = jellyfinHost.endsWith('/') + // ? jellyfinHost.slice(0, -1) + // : jellyfinHost; if (this.jellyfinMediaId) { this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 8b37bc85e..c541eeefe 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -16,6 +16,7 @@ import { User } from '@server/entity/User'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { getHostname } from '@server/utils/getHostname'; class AvailabilitySync { public running = false; @@ -84,7 +85,8 @@ class AvailabilitySync { ) { if (admin) { this.jellyfinClient = new JellyfinAPI( - settings.jellyfin.hostname ?? '', + // settings.jellyfin. ?? '', + getHostname(), admin.jellyfinAuthToken, admin.jellyfinDeviceId ); diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index f5b0f66a2..57edec476 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -12,6 +12,7 @@ import type { Library } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import AsyncLock from '@server/utils/asyncLock'; +import { getHostname } from '@server/utils/getHostname'; import { randomUUID as uuid } from 'crypto'; import { uniqWith } from 'lodash'; @@ -590,8 +591,10 @@ class JellyfinScanner { return this.log('No admin configured. Jellyfin sync skipped.', 'warn'); } + const hostname = getHostname(); + this.jfClient = new JellyfinAPI( - settings.jellyfin.hostname ?? '', + hostname, admin.jellyfinAuthToken, admin.jellyfinDeviceId ); diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 63f952363..dda681df8 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -38,7 +38,10 @@ export interface PlexSettings { export interface JellyfinSettings { name: string; - hostname: string; + ip: string; + port: number; + useSsl?: boolean; + urlBase?: string; externalHostname?: string; jellyfinForgotPasswordUrl?: string; libraries: Library[]; @@ -331,7 +334,9 @@ class Settings { }, jellyfin: { name: '', - hostname: '', + ip: '', + port: 8096, + useSsl: false, externalHostname: '', jellyfinForgotPasswordUrl: '', libraries: [], @@ -547,8 +552,8 @@ class Settings { region: this.data.main.region, originalLanguage: this.data.main.originalLanguage, mediaServerType: this.main.mediaServerType, - jellyfinHost: this.jellyfin.hostname, - jellyfinExternalHost: this.jellyfin.externalHostname, + // jellyfinHost: this.jellyfin.hostname, + // jellyfinExternalHost: this.jellyfin.externalHostname, partialRequestsEnabled: this.data.main.partialRequestsEnabled, cacheImages: this.data.main.cacheImages, vapidPublic: this.vapidPublic, @@ -637,8 +642,60 @@ class Settings { const data = fs.readFileSync(SETTINGS_PATH, 'utf-8'); if (data) { - this.data = merge(this.data, JSON.parse(data)); - this.save(); + const oldJellyfinSettings = JSON.parse(data).jellyfin; + + // Migrate old settings + // TODO: Test this migration and the regex + console.log(oldJellyfinSettings); + + if (oldJellyfinSettings && oldJellyfinSettings.hostname) { + // migrate old jellyfin hostname to ip and port and useSsl + const hostname = oldJellyfinSettings.hostname; + + const protocolMatch = hostname.match(/^(https?):\/\//i); + + if (protocolMatch) { + this.data.jellyfin.useSsl = true; + } + + const remainingUrl = hostname.replace(/^(https?):\/\//i, ''); + + const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/); + if (urlMatch) { + this.data.jellyfin.ip = urlMatch[1]; + this.data.jellyfin.port = urlMatch[3] || ''; + this.data.jellyfin.urlBase = urlMatch[4] || ''; + + if (!this.data.jellyfin.port && this.data.jellyfin.useSsl) { + this.data.jellyfin.port = 443; + } + + if ( + this.data.jellyfin.urlBase && + this.data.jellyfin.urlBase.endsWith('/') + ) { + this.data.jellyfin.urlBase = this.data.jellyfin.urlBase.slice( + 0, + -1 + ); + } + } + + delete oldJellyfinSettings.hostname; + + console.log(this.data.jellyfin, oldJellyfinSettings.hostname); + + this.data.jellyfin = Object.assign( + {}, + this.data.jellyfin, + oldJellyfinSettings + ); + + this.save(); + } else { + this.data = merge(this.data, JSON.parse(data)); + this.save(); + } } return this; } diff --git a/server/routes/auth.ts b/server/routes/auth.ts index c0f789a10..dca582442 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -11,6 +11,7 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { ApiError } from '@server/types/error'; +import { getHostname } from '@server/utils/getHostname'; import * as EmailValidator from 'email-validator'; import { Router } from 'express'; import gravatarUrl from 'gravatar-url'; @@ -221,30 +222,39 @@ authRoutes.post('/jellyfin', async (req, res, next) => { username?: string; password?: string; hostname?: string; + port?: number; + urlBase?: string; + useSsl?: boolean; email?: string; }; //Make sure jellyfin login is enabled, but only if jellyfin is not already configured if ( settings.main.mediaServerType !== MediaServerType.JELLYFIN && - settings.jellyfin.hostname !== '' + settings.jellyfin.ip !== '' ) { return res.status(500).json({ error: 'Jellyfin login is disabled' }); } else if (!body.username) { return res.status(500).json({ error: 'You must provide an username' }); - } else if (settings.jellyfin.hostname !== '' && body.hostname) { + } else if (settings.jellyfin.ip !== '' && body.hostname) { return res .status(500) .json({ error: 'Jellyfin hostname already configured' }); - } else if (settings.jellyfin.hostname === '' && !body.hostname) { + } else if (settings.jellyfin.ip === '' && !body.hostname) { return res.status(500).json({ error: 'No hostname provided.' }); } try { const hostname = - settings.jellyfin.hostname !== '' - ? settings.jellyfin.hostname - : body.hostname ?? ''; + settings.jellyfin.ip !== '' + ? getHostname() + : getHostname({ + useSsl: body.useSsl, + ip: body.hostname, + port: body.port, + urlBase: body.urlBase, + }); + const { externalHostname } = getSettings().jellyfin; // Try to find deviceId that corresponds to jellyfin user, else generate a new one @@ -260,17 +270,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => { 'base64' ); } + // First we need to attempt to log the user in to jellyfin - const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); - let jellyfinHost = + const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId); + const jellyfinHost = externalHostname && externalHostname.length > 0 ? externalHostname : hostname; - jellyfinHost = jellyfinHost.endsWith('/') - ? jellyfinHost.slice(0, -1) - : jellyfinHost; - const ip = req.ip ? req.ip.split(':').reverse()[0] : undefined; const account = await jellyfinserver.login( body.username, @@ -314,7 +321,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => { userType: UserType.JELLYFIN, }); - settings.jellyfin.hostname = body.hostname ?? ''; + settings.jellyfin.ip = body.hostname ?? ''; + settings.jellyfin.port = body.port ?? 8096; + settings.jellyfin.urlBase = body.urlBase ?? ''; + settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.serverId = account.User.ServerId; settings.save(); startJobs(); @@ -430,7 +440,9 @@ authRoutes.post('/jellyfin', async (req, res, next) => { label: 'Auth', error: e.errorCode, status: e.statusCode, - hostname: body.hostname, + hostname: `${body.useSsl ? 'https' : 'http'}://${body.hostname}:${ + body.port + }${body.urlBase}`, } ); return next({ diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 41821dcac..020f5a03f 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin'; import PlexAPI from '@server/api/plexapi'; import PlexTvAPI from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; +import { ApiErrorCode } from '@server/constants/error'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; @@ -26,6 +27,7 @@ import { isAuthenticated } from '@server/middleware/auth'; import discoverSettingRoutes from '@server/routes/settings/discover'; import { appDataPath } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; +import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; @@ -252,11 +254,48 @@ settingsRoutes.get('/jellyfin', (_req, res) => { res.status(200).json(settings.jellyfin); }); -settingsRoutes.post('/jellyfin', (req, res) => { +settingsRoutes.post('/jellyfin', async (req, res, next) => { + const userRepository = getRepository(User); const settings = getSettings(); - settings.jellyfin = merge(settings.jellyfin, req.body); - settings.save(); + try { + const admin = await userRepository.findOneOrFail({ + where: { id: 1 }, + select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'], + order: { id: 'ASC' }, + }); + + Object.assign(settings.jellyfin, req.body); + + const jellyfinClient = new JellyfinAPI( + getHostname(), + admin.jellyfinAuthToken ?? '', + admin.jellyfinDeviceId ?? '' + ); + + const result = await jellyfinClient.getSystemInfo(); + + console.log(result); + + // TODO: use the apiErrorCodes + if (!result?.data?.Id) { + throw new Error('Server not found'); + } + + settings.jellyfin.serverId = result.Id; + settings.jellyfin.name = result.ServerName; + + settings.save(); + } catch (e) { + logger.error('Something went wrong testing Jellyfin connection', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to connect to Jellyfin.', + }); + } return res.status(200).json(settings.jellyfin); }); @@ -272,7 +311,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( - settings.jellyfin.hostname ?? '', + getHostname(), admin.jellyfinAuthToken ?? '', admin.jellyfinDeviceId ?? '' ); @@ -288,10 +327,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { // Automatic Library grouping is not supported when user views are used to get library if (account.Configuration.GroupedFolders.length > 0) { - return next({ status: 501, message: 'SYNC_ERROR_GROUPED_FOLDERS' }); + return next({ + status: 501, + message: ApiErrorCode.SyncErrorGroupedFolders, + }); } - return next({ status: 404, message: 'SYNC_ERROR_NO_LIBRARIES' }); + return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries }); } const newLibraries: Library[] = libraries.map((library) => { @@ -322,16 +364,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { }); settingsRoutes.get('/jellyfin/users', async (req, res) => { - const settings = getSettings(); - const { hostname, externalHostname } = getSettings().jellyfin; - let jellyfinHost = + const { ip, port, useSsl, urlBase, externalHostname } = + getSettings().jellyfin; + const jellyfinHost = externalHostname && externalHostname.length > 0 ? externalHostname - : hostname; + : `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`; - jellyfinHost = jellyfinHost.endsWith('/') - ? jellyfinHost.slice(0, -1) - : jellyfinHost; const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], @@ -339,7 +378,6 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( - settings.jellyfin.hostname ?? '', admin.jellyfinAuthToken ?? '', admin.jellyfinDeviceId ?? '' ); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 789c90765..37931f08f 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -496,7 +496,6 @@ router.post( order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( - settings.jellyfin.hostname ?? '', admin.jellyfinAuthToken ?? '', admin.jellyfinDeviceId ?? '' ); @@ -504,15 +503,15 @@ router.post( //const jellyfinUsersResponse = await jellyfinClient.getUsers(); const createdUsers: User[] = []; - const { hostname, externalHostname } = getSettings().jellyfin; - let jellyfinHost = + const { ip, port, urlBase, useSsl, externalHostname } = + getSettings().jellyfin; + const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`; + + const jellyfinHost = externalHostname && externalHostname.length > 0 ? externalHostname : hostname; - jellyfinHost = jellyfinHost.endsWith('/') - ? jellyfinHost.slice(0, -1) - : jellyfinHost; jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); const jellyfinUsers = await jellyfinClient.getUsers(); diff --git a/server/utils/getHostname.ts b/server/utils/getHostname.ts new file mode 100644 index 000000000..9fa110cd1 --- /dev/null +++ b/server/utils/getHostname.ts @@ -0,0 +1,18 @@ +import { getSettings } from '@server/lib/settings'; + +interface HostnameParams { + useSsl?: boolean; + ip?: string; + port?: number; + urlBase?: string; +} + +export const getHostname = (params?: HostnameParams): string => { + const settings = params ? params : getSettings().jellyfin; + + const { useSsl, ip, port, urlBase } = settings; + + const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`; + + return hostname; +}; diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 7403392e9..0996bf0fa 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -14,7 +14,10 @@ import * as Yup from 'yup'; const messages = defineMessages({ username: 'Username', password: 'Password', - host: '{mediaServerName} URL', + 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.', @@ -24,6 +27,11 @@ const messages = defineMessages({ validationemailformat: 'Valid email required', validationusernamerequired: 'Username required', validationpasswordrequired: 'Password required', + 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.', credentialerror: 'The username or password is incorrect.', @@ -51,16 +59,34 @@ const JellyfinLogin: React.FC = ({ if (initial) { const LoginSchema = Yup.object().shape({ - host: Yup.string() + // host: Yup.string() + // .matches( + // /^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/, + // intl.formatMessage(messages.validationhostformat) + // ) + // .required( + // intl.formatMessage(messages.validationhostrequired, { + // mediaServerName: + // publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', + // }) + // ), + hostname: Yup.string().required( + intl.formatMessage(messages.validationhostrequired, { + mediaServerName: + publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', + }) + ), + port: Yup.number().required( + intl.formatMessage(messages.validationPortRequired) + ), + urlBase: Yup.string() .matches( - /^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/, - intl.formatMessage(messages.validationhostformat) + /^(\/[^/].*[^/]$)/, + intl.formatMessage(messages.validationUrlBaseLeadingSlash) ) - .required( - intl.formatMessage(messages.validationhostrequired, { - mediaServerName: - publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', - }) + .matches( + /^(.*[^/])$/, + intl.formatMessage(messages.validationUrlBaseTrailingSlash) ), email: Yup.string() .email(intl.formatMessage(messages.validationemailformat)) @@ -75,12 +101,16 @@ const JellyfinLogin: React.FC = ({ mediaServerName: publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', }; + return ( = ({ await axios.post('/api/v1/auth/jellyfin', { username: values.username, password: values.password, - hostname: values.host, + hostname: values.hostname, + port: values.port, + useSsl: values.useSsl, + urlBase: values.urlBase, email: values.email, }); } catch (e) { + console.log(e); let errorMessage = null; switch (e.response?.data?.message) { case ApiErrorCode.InvalidUrl: @@ -121,13 +155,20 @@ const JellyfinLogin: React.FC = ({ } }} > - {({ errors, touched, isSubmitting, isValid }) => ( + {({ + errors, + touched, + values, + setFieldValue, + isSubmitting, + isValid, + }) => (
-