navidrome/ui/src/user/UserEdit.jsx
Deluan Quintão e80a7937e8
fix(ui): make self-service profile edits report their outcome (#5699)
* fix(ui): make self-service profile edits report their outcome

When a non-admin user saved their own profile (e.g. changing their
password via EnableUserEditing), the data provider followed the user
update with a call to the admin-only PUT /api/user/{id}/library
endpoint, which always failed with 403. The save error handler then
crashed reading error.body.errors on the plain-text response, so the
user got no notification at all - while the profile change had in fact
already been applied. This made password changes look like they were
silently ignored, and follow-up attempts failed with 'password does not
match' since the current password had already changed. Present since
the multi-library support introduced in v0.58.0 (#4181).

Only call the user-library association endpoint when the logged-in user
is an admin (the server manages assignments for self-edits), and make
the save error handler tolerate error bodies without field errors,
notifying a generic error instead of crashing.

* fix(ui): tolerate nullish rejection values in user save handler

Address review feedback: use optional chaining on the error itself in
the UserEdit save handler, so a nullish rejection value also results in
the generic error notification instead of a TypeError.
2026-07-01 21:31:29 -04:00

184 lines
5.1 KiB
JavaScript

import React, { useCallback } from 'react'
import { makeStyles } from '@material-ui/core/styles'
import {
TextInput,
BooleanInput,
DateField,
PasswordInput,
Edit,
required,
email,
SimpleForm,
useTranslate,
Toolbar,
SaveButton,
useMutation,
useNotify,
useRedirect,
useRefresh,
FormDataConsumer,
usePermissions,
useRecordContext,
} from 'react-admin'
import { Typography } from '@material-ui/core'
import { Title } from '../common'
import DeleteUserButton from './DeleteUserButton'
import { LibrarySelectionField } from './LibrarySelectionField.jsx'
import { validateUserForm } from './userValidation'
const useStyles = makeStyles({
toolbar: {
display: 'flex',
justifyContent: 'space-between',
},
})
const UserTitle = ({ record }) => {
const translate = useTranslate()
const resourceName = translate('resources.user.name', { smart_count: 1 })
return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} />
}
const UserToolbar = ({ showDelete, ...props }) => (
<Toolbar {...props} classes={useStyles()}>
<SaveButton disabled={props.pristine} />
{showDelete && <DeleteUserButton />}
</Toolbar>
)
const CurrentPasswordInput = ({ formData, isMyself, ...rest }) => {
const { permissions } = usePermissions()
return formData.changePassword && (isMyself || permissions !== 'admin') ? (
<PasswordInput className="ra-input" source="currentPassword" {...rest} />
) : null
}
const NewPasswordInput = ({ formData, ...rest }) => {
const translate = useTranslate()
return formData.changePassword ? (
<PasswordInput
source="password"
className="ra-input"
label={translate('resources.user.fields.newPassword')}
{...rest}
/>
) : null
}
const UserEdit = (props) => {
const { permissions } = props
const translate = useTranslate()
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
const refresh = useRefresh()
const isMyself = props.id === localStorage.getItem('userId')
const getNameHelperText = () =>
isMyself && {
helperText: translate('resources.user.helperTexts.name'),
}
const canDelete = permissions === 'admin' && !isMyself
const save = useCallback(
async (values) => {
try {
await mutate(
{
type: 'update',
resource: 'user',
payload: { id: values.id, data: values },
},
{ returnPromise: true },
)
notify('resources.user.notifications.updated', 'info', {
smart_count: 1,
})
permissions === 'admin' ? redirect('/user') : refresh()
} catch (error) {
if (error?.body?.errors) {
return error.body.errors
}
notify('ra.page.error', 'warning')
}
},
[mutate, notify, permissions, redirect, refresh],
)
// Custom validation function
const validateForm = (values) => {
return validateUserForm(values, translate)
}
return (
<Edit title={<UserTitle />} undoable={false} {...props}>
<SimpleForm
variant={'outlined'}
toolbar={<UserToolbar showDelete={canDelete} />}
save={save}
validate={validateForm}
>
{permissions === 'admin' && (
<TextInput
spellCheck={false}
source="userName"
validate={[required()]}
/>
)}
<TextInput
source="name"
validate={[required()]}
{...getNameHelperText()}
/>
<TextInput spellCheck={false} source="email" validate={[email()]} />
<BooleanInput source="changePassword" />
<FormDataConsumer>
{(formDataProps) => (
<CurrentPasswordInput
spellCheck={false}
isMyself={isMyself}
{...formDataProps}
/>
)}
</FormDataConsumer>
<FormDataConsumer>
{(formDataProps) => (
<NewPasswordInput spellCheck={false} {...formDataProps} />
)}
</FormDataConsumer>
{permissions === 'admin' && (
<BooleanInput source="isAdmin" initialValue={false} />
)}
{/* Conditional Library Selection for Admin Users Only */}
{permissions === 'admin' && (
<FormDataConsumer>
{({ formData }) => (
<>
{!formData.isAdmin && <LibrarySelectionField />}
{formData.isAdmin && (
<Typography
variant="body2"
color="textSecondary"
style={{ marginTop: 16, marginBottom: 16 }}
>
{translate('resources.user.message.adminAutoLibraries')}
</Typography>
)}
</>
)}
</FormDataConsumer>
)}
<DateField variant="body1" source="lastLoginAt" showTime />
<DateField variant="body1" source="lastAccessAt" showTime />
<DateField variant="body1" source="updatedAt" showTime />
<DateField variant="body1" source="createdAt" showTime />
</SimpleForm>
</Edit>
)
}
export default UserEdit