Files
navidrome/ui/src/plugin/PluginShow.jsx
T
Deluan Quintão d9a215e1e3 feat(plugins): allow mounting library directories as read-write (#5122)
* feat(plugins): mount library directories as read-only by default

Add an AllowWriteAccess boolean to the plugin model, defaulting to
false. When off, library directories are mounted with the extism "ro:"
prefix (read-only). Admins can explicitly grant write access via a new
toggle in the Library Permission card.

* test: add tests to buildAllowedPaths

Signed-off-by: Deluan <deluan@navidrome.org>

* chore: improve allowed paths logging for library access

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-28 10:59:13 -05:00

351 lines
10 KiB
React

import React, { useState, useCallback, useMemo } from 'react'
import {
ShowContextProvider,
useShowController,
useShowContext,
useTranslate,
useUpdate,
useNotify,
useRefresh,
Title as RaTitle,
Loading,
} from 'react-admin'
import { Box, useMediaQuery, Button } from '@material-ui/core'
import { MdSave } from 'react-icons/md'
import Alert from '@material-ui/lab/Alert'
import { Title, useResourceRefresh } from '../common'
import { usePluginShowStyles } from './styles.js'
import { ErrorSection } from './ErrorSection'
import { StatusCard } from './StatusCard'
import { InfoCard } from './InfoCard'
import { ManifestSection } from './ManifestSection'
import { ConfigCard } from './ConfigCard'
import { UsersPermissionCard } from './UsersPermissionCard'
import { LibraryPermissionCard } from './LibraryPermissionCard'
// Main show layout component
const PluginShowLayout = () => {
const { record, isPending, error } = useShowContext()
const classes = usePluginShowStyles()
const translate = useTranslate()
const notify = useNotify()
const refresh = useRefresh()
const isSmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
useResourceRefresh('plugin')
const [configData, setConfigData] = useState({})
const [configErrors, setConfigErrors] = useState([])
const [isDirty, setIsDirty] = useState(false)
const [lastRecordConfig, setLastRecordConfig] = useState(null)
const [isConfigInitialized, setIsConfigInitialized] = useState(false)
// Users permission state
const [selectedUsers, setSelectedUsers] = useState([])
const [allUsers, setAllUsers] = useState(false)
const [lastRecordUsers, setLastRecordUsers] = useState(null)
const [lastRecordAllUsers, setLastRecordAllUsers] = useState(null)
// Libraries permission state
const [selectedLibraries, setSelectedLibraries] = useState([])
const [allLibraries, setAllLibraries] = useState(false)
const [allowWriteAccess, setAllowWriteAccess] = useState(false)
const [lastRecordLibraries, setLastRecordLibraries] = useState(null)
const [lastRecordAllLibraries, setLastRecordAllLibraries] = useState(null)
const [lastRecordAllowWriteAccess, setLastRecordAllowWriteAccess] =
useState(null)
// Parse JSON config to object
const jsonToObject = useCallback((jsonString) => {
if (!jsonString || jsonString.trim() === '') return {}
try {
return JSON.parse(jsonString)
} catch {
return {}
}
}, [])
// Initialize/update config when record loads or changes (e.g., from SSE refresh)
React.useEffect(() => {
const recordConfig = record?.config || ''
if (record && recordConfig !== lastRecordConfig && !isDirty) {
setConfigData(jsonToObject(recordConfig))
setLastRecordConfig(recordConfig)
// Reset initialization flag - AJV will apply defaults on first render
setIsConfigInitialized(false)
}
}, [record, lastRecordConfig, isDirty, jsonToObject])
// Initialize/update users permission state when record loads or changes
React.useEffect(() => {
if (record && !isDirty) {
const recordUsers = record.users || ''
const recordAllUsers = record.allUsers || false
if (
recordUsers !== lastRecordUsers ||
recordAllUsers !== lastRecordAllUsers
) {
try {
setSelectedUsers(recordUsers ? JSON.parse(recordUsers) : [])
} catch {
setSelectedUsers([])
}
setAllUsers(recordAllUsers)
setLastRecordUsers(recordUsers)
setLastRecordAllUsers(recordAllUsers)
}
}
}, [record, lastRecordUsers, lastRecordAllUsers, isDirty])
// Initialize/update libraries permission state when record loads or changes
React.useEffect(() => {
if (record && !isDirty) {
const recordLibraries = record.libraries || ''
const recordAllLibraries = record.allLibraries || false
const recordAllowWriteAccess = record.allowWriteAccess || false
if (
recordLibraries !== lastRecordLibraries ||
recordAllLibraries !== lastRecordAllLibraries ||
recordAllowWriteAccess !== lastRecordAllowWriteAccess
) {
try {
setSelectedLibraries(
recordLibraries ? JSON.parse(recordLibraries) : [],
)
} catch {
setSelectedLibraries([])
}
setAllLibraries(recordAllLibraries)
setAllowWriteAccess(recordAllowWriteAccess)
setLastRecordLibraries(recordLibraries)
setLastRecordAllLibraries(recordAllLibraries)
setLastRecordAllowWriteAccess(recordAllowWriteAccess)
}
}
}, [
record,
lastRecordLibraries,
lastRecordAllLibraries,
lastRecordAllowWriteAccess,
isDirty,
])
const handleConfigDataChange = useCallback(
(newData, errors) => {
setConfigData(newData)
setConfigErrors(errors || [])
// Skip marking dirty on initial onChange (when AJV applies defaults)
if (isConfigInitialized) {
setIsDirty(true)
} else {
setIsConfigInitialized(true)
}
},
[isConfigInitialized],
)
const handleSelectedUsersChange = useCallback((newSelectedUsers) => {
setSelectedUsers(newSelectedUsers)
setIsDirty(true)
}, [])
const handleAllUsersChange = useCallback((newAllUsers) => {
setAllUsers(newAllUsers)
setIsDirty(true)
}, [])
const handleSelectedLibrariesChange = useCallback((newSelectedLibraries) => {
setSelectedLibraries(newSelectedLibraries)
setIsDirty(true)
}, [])
const handleAllLibrariesChange = useCallback((newAllLibraries) => {
setAllLibraries(newAllLibraries)
setIsDirty(true)
}, [])
const handleAllowWriteAccessChange = useCallback((newAllowWriteAccess) => {
setAllowWriteAccess(newAllowWriteAccess)
setIsDirty(true)
}, [])
const [updatePlugin, { loading }] = useUpdate(
'plugin',
record?.id,
{},
record,
{
undoable: false,
onSuccess: () => {
refresh()
setIsDirty(false)
setLastRecordConfig(null) // Reset to reinitialize from server
setLastRecordUsers(null)
setLastRecordAllUsers(null)
setLastRecordLibraries(null)
setLastRecordAllLibraries(null)
setLastRecordAllowWriteAccess(null)
notify('resources.plugin.notifications.updated', 'info')
},
onFailure: (err) => {
notify(
err?.message || 'resources.plugin.notifications.error',
'warning',
)
},
},
)
const handleSaveConfig = useCallback(() => {
if (!record) return
const parsedManifest = record.manifest ? JSON.parse(record.manifest) : null
const data = {}
// Only include config if the plugin has a config schema
if (parsedManifest?.config?.schema) {
data.config =
Object.keys(configData).length > 0 ? JSON.stringify(configData) : ''
}
// Include users data if users permission is present
if (parsedManifest?.permissions?.users) {
data.users = JSON.stringify(selectedUsers)
data.allUsers = allUsers
}
// Include libraries data if library permission is present
if (parsedManifest?.permissions?.library) {
data.libraries = JSON.stringify(selectedLibraries)
data.allLibraries = allLibraries
data.allowWriteAccess = allowWriteAccess
}
updatePlugin('plugin', record.id, data, record)
}, [
updatePlugin,
record,
configData,
selectedUsers,
allUsers,
selectedLibraries,
allLibraries,
allowWriteAccess,
])
// Parse manifest
const { manifest, manifestJson } = useMemo(() => {
if (!record?.manifest) return { manifest: null, manifestJson: '' }
try {
const parsed = JSON.parse(record.manifest)
return { manifest: parsed, manifestJson: JSON.stringify(parsed, null, 2) }
} catch {
return { manifest: null, manifestJson: record.manifest }
}
}, [record?.manifest])
// Handle loading state
if (isPending) {
return <Loading />
}
// Handle error state
if (error) {
return (
<Alert severity="error">{translate('ra.notification.http_error')}</Alert>
)
}
// Handle missing record
if (!record) {
return null
}
return (
<>
<RaTitle
title={
<Title
subTitle={`${translate('resources.plugin.name', { smart_count: 1 })} "${record.id}"`}
/>
}
/>
<Box className={classes.root}>
<ErrorSection error={record.lastError} translate={translate} />
<StatusCard
classes={classes}
translate={translate}
manifest={manifest}
/>
<InfoCard
record={record}
manifest={manifest}
classes={classes}
translate={translate}
isSmall={isSmall}
/>
<ManifestSection
manifestJson={manifestJson}
classes={classes}
translate={translate}
/>
<ConfigCard
manifest={manifest}
configData={configData}
onConfigDataChange={handleConfigDataChange}
classes={classes}
translate={translate}
/>
<UsersPermissionCard
manifest={manifest}
classes={classes}
selectedUsers={selectedUsers}
allUsers={allUsers}
onSelectedUsersChange={handleSelectedUsersChange}
onAllUsersChange={handleAllUsersChange}
/>
<LibraryPermissionCard
manifest={manifest}
classes={classes}
selectedLibraries={selectedLibraries}
allLibraries={allLibraries}
allowWriteAccess={allowWriteAccess}
onSelectedLibrariesChange={handleSelectedLibrariesChange}
onAllLibrariesChange={handleAllLibrariesChange}
onAllowWriteAccessChange={handleAllowWriteAccessChange}
/>
<Box display="flex" justifyContent="flex-end">
<Button
variant="contained"
color="primary"
startIcon={<MdSave />}
onClick={handleSaveConfig}
disabled={!isDirty || loading || configErrors.length > 0}
className={classes.saveButton}
>
{translate('ra.action.save')}
</Button>
</Box>
</Box>
</>
)
}
const PluginShow = (props) => {
const controllerProps = useShowController(props)
return (
<ShowContextProvider value={controllerProps}>
<PluginShowLayout />
</ShowContextProvider>
)
}
export default PluginShow