Build 2602262030: add magent settings and hardening

This commit is contained in:
2026-02-26 20:31:26 +13:00
parent b215e8030c
commit 0b73d9f4ee
16 changed files with 897 additions and 140 deletions

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
type AdminSetting = {
@@ -19,6 +19,7 @@ type ServiceOptions = {
}
const SECTION_LABELS: Record<string, string> = {
magent: 'Magent',
jellyseerr: 'Jellyseerr',
jellyfin: 'Jellyfin',
artwork: 'Artwork cache',
@@ -32,9 +33,34 @@ const SECTION_LABELS: Record<string, string> = {
site: 'Site',
}
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
const BOOL_SETTINGS = new Set([
'jellyfin_sync_to_arr',
'site_banner_enabled',
'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled',
'magent_notify_enabled',
'magent_notify_email_enabled',
'magent_notify_email_use_tls',
'magent_notify_email_use_ssl',
'magent_notify_discord_enabled',
'magent_notify_telegram_enabled',
'magent_notify_push_enabled',
'magent_notify_webhook_enabled',
])
const TEXTAREA_SETTINGS = new Set([
'site_banner_message',
'site_changelog',
'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem',
])
const URL_SETTINGS = new Set([
'magent_application_url',
'magent_api_url',
'magent_proxy_base_url',
'magent_notify_discord_webhook_url',
'magent_notify_push_base_url',
'magent_notify_webhook_url',
'jellyseerr_base_url',
'jellyfin_base_url',
'jellyfin_public_url',
@@ -43,9 +69,20 @@ const URL_SETTINGS = new Set([
'prowlarr_base_url',
'qbittorrent_base_url',
])
const NUMBER_SETTINGS = new Set([
'magent_application_port',
'magent_api_port',
'magent_notify_email_smtp_port',
'requests_sync_ttl_minutes',
'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes',
'requests_cleanup_days',
])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = {
magent:
'Application-level Magent settings including proxy, binding, TLS, and notification channels.',
jellyseerr: 'Connect the request system where users submit content.',
jellyfin: 'Control Jellyfin login and availability checks.',
artwork: 'Cache posters/backdrops and review artwork coverage.',
@@ -60,6 +97,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
}
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
magent: 'magent',
jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin',
artwork: null,
@@ -74,7 +112,151 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
site: 'site',
}
const MAGENT_SECTION_GROUPS: Array<{
key: string
title: string
description: string
keys: string[]
}> = [
{
key: 'magent-runtime',
title: 'Application',
description:
'Canonical application/API URLs and port defaults for the Magent UI/API endpoints.',
keys: [
'magent_application_url',
'magent_application_port',
'magent_api_url',
'magent_api_port',
'magent_bind_host',
],
},
{
key: 'magent-proxy',
title: 'Proxy',
description:
'Reverse proxy awareness and base URL handling when Magent sits behind Caddy/NGINX/Traefik.',
keys: [
'magent_proxy_enabled',
'magent_proxy_base_url',
'magent_proxy_trust_forwarded_headers',
'magent_proxy_forwarded_prefix',
],
},
{
key: 'magent-ssl',
title: 'Manual SSL Bind',
description:
'Optional direct TLS binding values. Paste PEM certificate and private key or provide file paths.',
keys: [
'magent_ssl_bind_enabled',
'magent_ssl_certificate_path',
'magent_ssl_private_key_path',
'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem',
],
},
{
key: 'magent-notify-core',
title: 'Notifications',
description:
'Global notification controls and provider-independent defaults used by Magent messaging features.',
keys: ['magent_notify_enabled'],
},
{
key: 'magent-notify-email',
title: 'Email',
description: 'SMTP configuration for email notifications.',
keys: [
'magent_notify_email_enabled',
'magent_notify_email_smtp_host',
'magent_notify_email_smtp_port',
'magent_notify_email_smtp_username',
'magent_notify_email_smtp_password',
'magent_notify_email_from_address',
'magent_notify_email_from_name',
'magent_notify_email_use_tls',
'magent_notify_email_use_ssl',
],
},
{
key: 'magent-notify-discord',
title: 'Discord',
description: 'Webhook settings for Discord notifications and feedback routing.',
keys: ['magent_notify_discord_enabled', 'magent_notify_discord_webhook_url'],
},
{
key: 'magent-notify-telegram',
title: 'Telegram',
description: 'Bot token and chat target for Telegram notifications.',
keys: [
'magent_notify_telegram_enabled',
'magent_notify_telegram_bot_token',
'magent_notify_telegram_chat_id',
],
},
{
key: 'magent-notify-push',
title: 'Push / Mobile',
description:
'Generic push messaging configuration (ntfy, Gotify, Pushover, webhook-style push endpoints).',
keys: [
'magent_notify_push_enabled',
'magent_notify_push_provider',
'magent_notify_push_base_url',
'magent_notify_push_topic',
'magent_notify_push_token',
'magent_notify_push_user_key',
'magent_notify_push_device',
'magent_notify_webhook_enabled',
'magent_notify_webhook_url',
],
},
]
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_application_url: 'Application URL',
magent_application_port: 'Application port',
magent_api_url: 'API URL',
magent_api_port: 'API port',
magent_bind_host: 'Bind host',
magent_proxy_enabled: 'Proxy support enabled',
magent_proxy_base_url: 'Proxy base URL',
magent_proxy_trust_forwarded_headers: 'Trust forwarded headers',
magent_proxy_forwarded_prefix: 'Forwarded path prefix',
magent_ssl_bind_enabled: 'Manual SSL bind enabled',
magent_ssl_certificate_path: 'Certificate path',
magent_ssl_private_key_path: 'Private key path',
magent_ssl_certificate_pem: 'Certificate (PEM)',
magent_ssl_private_key_pem: 'Private key (PEM)',
magent_notify_enabled: 'Notifications enabled',
magent_notify_email_enabled: 'Email notifications enabled',
magent_notify_email_smtp_host: 'SMTP host',
magent_notify_email_smtp_port: 'SMTP port',
magent_notify_email_smtp_username: 'SMTP username',
magent_notify_email_smtp_password: 'SMTP password',
magent_notify_email_from_address: 'From email address',
magent_notify_email_from_name: 'From display name',
magent_notify_email_use_tls: 'Use STARTTLS',
magent_notify_email_use_ssl: 'Use SSL/TLS (implicit)',
magent_notify_discord_enabled: 'Discord notifications enabled',
magent_notify_discord_webhook_url: 'Discord webhook URL',
magent_notify_telegram_enabled: 'Telegram notifications enabled',
magent_notify_telegram_bot_token: 'Telegram bot token',
magent_notify_telegram_chat_id: 'Telegram chat ID',
magent_notify_push_enabled: 'Push notifications enabled',
magent_notify_push_provider: 'Push provider',
magent_notify_push_base_url: 'Push provider/base URL',
magent_notify_push_topic: 'Topic / channel',
magent_notify_push_token: 'API token / password',
magent_notify_push_user_key: 'User key / recipient key',
magent_notify_push_device: 'Device / target',
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
magent_notify_webhook_url: 'Generic webhook URL',
}
const labelFromKey = (key: string) =>
SETTING_LABEL_OVERRIDES[key] ??
key
.replaceAll('_', ' ')
.replace('base url', 'URL')
@@ -115,6 +297,13 @@ type SettingsPageProps = {
section: string
}
type SettingsSectionGroup = {
key: string
title: string
items: AdminSetting[]
description?: string
}
export default function SettingsPage({ section }: SettingsPageProps) {
const router = useRouter()
const [settings, setSettings] = useState<AdminSetting[]>([])
@@ -308,26 +497,56 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
const settingsSections = isCacheSection
const settingsSections: SettingsSectionGroup[] = isCacheSection
? [
{ key: 'cache', title: 'Cache control', items: cacheSettings },
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
]
: visibleSections.map((sectionKey) => ({
key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey,
items: (() => {
const sectionItems = groupedSettings[sectionKey] ?? []
const filtered =
sectionKey === 'requests' || sectionKey === 'artwork'
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
: sectionItems
if (sectionKey === 'requests') {
return sortByOrder(filtered, requestSettingOrder)
: section === 'magent'
? (() => {
const magentItems = groupedSettings.magent ?? []
const byKey = new Map(magentItems.map((item) => [item.key, item]))
const used = new Set<string>()
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.map((group) => {
const items = group.keys
.map((key) => byKey.get(key))
.filter((item): item is AdminSetting => Boolean(item))
for (const item of items) {
used.add(item.key)
}
return {
key: group.key,
title: group.title,
description: group.description,
items,
}
})
const remaining = magentItems.filter((item) => !used.has(item.key))
if (remaining.length) {
groups.push({
key: 'magent-other',
title: 'Additional Magent settings',
description: 'Uncategorized Magent settings.',
items: remaining,
})
}
return filtered
})(),
}))
return groups
})()
: visibleSections.map((sectionKey) => ({
key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey,
items: (() => {
const sectionItems = groupedSettings[sectionKey] ?? []
const filtered =
sectionKey === 'requests' || sectionKey === 'artwork'
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
: sectionItems
if (sectionKey === 'requests') {
return sortByOrder(filtered, requestSettingOrder)
}
return filtered
})(),
}))
const showLogs = section === 'logs'
const showMaintenance = section === 'maintenance'
const showRequestsExtras = section === 'requests'
@@ -350,6 +569,65 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [artworkPrefetch])
const settingDescriptions: Record<string, string> = {
magent_application_url:
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
magent_application_port:
'Preferred frontend/UI port for local or direct-hosted deployments.',
magent_api_url:
'Canonical public URL for the Magent API when it differs from the app URL.',
magent_api_port: 'Preferred API port for local or direct-hosted deployments.',
magent_bind_host:
'Host/IP to bind the application services to when running without an external process manager.',
magent_proxy_enabled:
'Enable reverse-proxy-aware behavior and use proxy-specific URL settings.',
magent_proxy_base_url:
'Base URL Magent should use when it is published behind a proxy path or external proxy hostname.',
magent_proxy_trust_forwarded_headers:
'Trust X-Forwarded-* headers from your reverse proxy.',
magent_proxy_forwarded_prefix:
'Optional path prefix added by your proxy (example: /magent).',
magent_ssl_bind_enabled:
'Enable direct HTTPS binding in Magent (for environments not terminating TLS at a proxy).',
magent_ssl_certificate_path:
'Path to the TLS certificate file on disk (PEM).',
magent_ssl_private_key_path:
'Path to the TLS private key file on disk (PEM).',
magent_ssl_certificate_pem:
'Paste the TLS certificate PEM if you want Magent to store it directly.',
magent_ssl_private_key_pem:
'Paste the TLS private key PEM if you want Magent to store it directly.',
magent_notify_enabled:
'Master switch for Magent notifications. Individual provider toggles still apply.',
magent_notify_email_enabled: 'Enable SMTP email notifications.',
magent_notify_email_smtp_host: 'SMTP server hostname or IP.',
magent_notify_email_smtp_port: 'SMTP port (587 for STARTTLS, 465 for SSL).',
magent_notify_email_smtp_username: 'SMTP account username.',
magent_notify_email_smtp_password: 'SMTP account password or app password.',
magent_notify_email_from_address: 'Sender email address used by Magent.',
magent_notify_email_from_name: 'Sender display name shown to recipients.',
magent_notify_email_use_tls: 'Use STARTTLS after connecting to SMTP.',
magent_notify_email_use_ssl: 'Use implicit TLS/SSL for SMTP (usually port 465).',
magent_notify_discord_enabled: 'Enable Discord webhook notifications.',
magent_notify_discord_webhook_url:
'Discord channel webhook URL used for notifications and optional feedback routing.',
magent_notify_telegram_enabled: 'Enable Telegram notifications.',
magent_notify_telegram_bot_token: 'Bot token from BotFather.',
magent_notify_telegram_chat_id:
'Default Telegram chat/group/user ID for notifications.',
magent_notify_push_enabled: 'Enable generic push notifications.',
magent_notify_push_provider:
'Push backend to target (ntfy, gotify, pushover, webhook, etc.).',
magent_notify_push_base_url:
'Base URL for your push provider (for example ntfy/gotify server URL).',
magent_notify_push_topic: 'Topic/channel/room name used by the push provider.',
magent_notify_push_token: 'Provider token/API key/password.',
magent_notify_push_user_key:
'Provider recipient key/user key (for example Pushover user key).',
magent_notify_push_device:
'Optional device or target override, depending on provider.',
magent_notify_webhook_enabled: 'Enable generic webhook notifications.',
magent_notify_webhook_url:
'Generic webhook endpoint for custom integrations or automation flows.',
jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
jellyseerr_api_key: 'API key used to read requests and status.',
@@ -397,6 +675,29 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
const settingPlaceholders: Record<string, string> = {
magent_application_url: 'https://magent.example.com',
magent_application_port: '3000',
magent_api_url: 'https://api.example.com or https://magent.example.com/api',
magent_api_port: '8000',
magent_bind_host: '0.0.0.0',
magent_proxy_base_url: 'https://proxy.example.com/magent',
magent_proxy_forwarded_prefix: '/magent',
magent_ssl_certificate_path: '/certs/fullchain.pem',
magent_ssl_private_key_path: '/certs/privkey.pem',
magent_ssl_certificate_pem: '-----BEGIN CERTIFICATE-----',
magent_ssl_private_key_pem: '-----BEGIN PRIVATE KEY-----',
magent_notify_email_smtp_host: 'smtp.office365.com',
magent_notify_email_smtp_port: '587',
magent_notify_email_smtp_username: 'notifications@example.com',
magent_notify_email_from_address: 'notifications@example.com',
magent_notify_email_from_name: 'Magent',
magent_notify_discord_webhook_url: 'https://discord.com/api/webhooks/...',
magent_notify_telegram_bot_token: '123456789:AA...',
magent_notify_telegram_chat_id: '-1001234567890',
magent_notify_push_base_url: 'https://ntfy.example.com or https://gotify.example.com',
magent_notify_push_topic: 'magent-alerts',
magent_notify_push_device: 'iphone-zak',
magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellyfin_public_url: 'https://jelly.example.com',
@@ -599,83 +900,101 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
const baseUrl = getApiBase()
const params = new URLSearchParams()
params.set('access_token', token)
if (showLogs) {
params.set('include_logs', '1')
params.set('log_lines', String(logsCount))
}
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
let closed = false
const source = new EventSource(streamUrl)
let source: EventSource | null = null
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
const connect = async () => {
try {
const payload = JSON.parse(event.data)
if (!payload || payload.type !== 'admin_live_state') {
return
const streamToken = await getEventStreamToken()
if (closed) return
const params = new URLSearchParams()
params.set('stream_token', streamToken)
if (showLogs) {
params.set('include_logs', '1')
params.set('log_lines', String(logsCount))
}
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
const rawSync =
payload.requestsSync && typeof payload.requestsSync === 'object'
? payload.requestsSync
: null
const nextSync = rawSync?.status === 'idle' ? null : rawSync
const prevSync = requestsSyncRef.current
requestsSyncRef.current = nextSync
setRequestsSync(nextSync)
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (!payload || payload.type !== 'admin_live_state') {
return
}
const rawArtwork =
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
? payload.artworkPrefetch
: null
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
const prevArtwork = artworkPrefetchRef.current
artworkPrefetchRef.current = nextArtwork
setArtworkPrefetch(nextArtwork)
if (
prevArtwork?.status === 'running' &&
nextArtwork?.status &&
nextArtwork.status !== 'running'
) {
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
if (showArtworkExtras) {
void loadArtworkSummary()
const rawSync =
payload.requestsSync && typeof payload.requestsSync === 'object'
? payload.requestsSync
: null
const nextSync = rawSync?.status === 'idle' ? null : rawSync
const prevSync = requestsSyncRef.current
requestsSyncRef.current = nextSync
setRequestsSync(nextSync)
if (
prevSync?.status === 'running' &&
nextSync?.status &&
nextSync.status !== 'running'
) {
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
}
const rawArtwork =
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
? payload.artworkPrefetch
: null
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
const prevArtwork = artworkPrefetchRef.current
artworkPrefetchRef.current = nextArtwork
setArtworkPrefetch(nextArtwork)
if (
prevArtwork?.status === 'running' &&
nextArtwork?.status &&
nextArtwork.status !== 'running'
) {
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
if (showArtworkExtras) {
void loadArtworkSummary()
}
}
if (payload.logs && typeof payload.logs === 'object') {
if (Array.isArray(payload.logs.lines)) {
setLogsLines(payload.logs.lines)
setLogsStatus(null)
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
setLogsStatus(payload.logs.error)
}
}
} catch (err) {
console.error(err)
}
}
if (payload.logs && typeof payload.logs === 'object') {
if (Array.isArray(payload.logs.lines)) {
setLogsLines(payload.logs.lines)
setLogsStatus(null)
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
setLogsStatus(payload.logs.error)
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
} catch (err) {
if (closed) return
console.error(err)
setLiveStreamConnected(false)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
void connect()
return () => {
closed = true
setLiveStreamConnected(false)
source.close()
source?.close()
}
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
@@ -1000,8 +1319,17 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div>
)}
</div>
{SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && (
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
(!settingsSection || section === 'magent') && (
<p className="section-subtitle">
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
</p>
)}
{section === 'magent' && sectionGroup.key === 'magent-runtime' && (
<div className="status-banner">
Runtime host/port and SSL values are configuration settings. Container/process
restarts may still be required before bind/port changes take effect.
</div>
)}
{sectionGroup.key === 'sonarr' && sonarrError && (
<div className="error-banner">{sonarrError}</div>
@@ -1339,6 +1667,35 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label>
)
}
if (setting.key === 'magent_notify_push_provider') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'ntfy'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="ntfy">ntfy</option>
<option value="gotify">Gotify</option>
<option value="pushover">Pushover</option>
<option value="webhook">Webhook</option>
<option value="telegram">Telegram relay</option>
<option value="discord">Discord relay</option>
</select>
</label>
)
}
if (
setting.key === 'requests_full_sync_time' ||
setting.key === 'requests_cleanup_time'
@@ -1365,10 +1722,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label>
)
}
if (
setting.key === 'requests_delta_sync_interval_minutes' ||
setting.key === 'requests_cleanup_days'
) {
if (NUMBER_SETTINGS.has(setting.key)) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
@@ -1381,6 +1735,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
name={setting.key}
type="number"
min={1}
step={1}
value={value}
onChange={(event) =>
setFormValues((current) => ({
@@ -1420,8 +1775,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
)
}
if (TEXTAREA_SETTINGS.has(setting.key)) {
const isPemField =
setting.key === 'magent_ssl_certificate_pem' ||
setting.key === 'magent_ssl_private_key_pem'
return (
<label key={setting.key} data-helper={helperText || undefined}>
<label
key={setting.key}
data-helper={helperText || undefined}
className={isPemField ? 'field-span-full' : undefined}
>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
@@ -1431,11 +1793,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</span>
<textarea
name={setting.key}
rows={setting.key === 'site_changelog' ? 6 : 3}
rows={setting.key === 'site_changelog' ? 6 : isPemField ? 8 : 3}
placeholder={
setting.key === 'site_changelog'
? 'One update per line.'
: ''
: settingPlaceholders[setting.key] ?? ''
}
value={value}
onChange={(event) =>