feat(ui): add avatar upload to the user edit page

This commit is contained in:
Deluan 2026-09-07 20:29:01 -04:00
parent 89d8fbbd13
commit e405723c43
3 changed files with 112 additions and 4 deletions

View File

@ -571,6 +571,12 @@
"coverRemoved": "Cover art removed",
"coverUploadError": "Error uploading cover art",
"coverRemoveError": "Error removing cover art",
"uploadAvatar": "Upload avatar",
"removeAvatar": "Remove avatar",
"avatarUploaded": "Avatar updated",
"avatarUploadError": "Error updating avatar",
"avatarRemoved": "Avatar removed",
"avatarRemoveError": "Error removing avatar",
"metadataRefreshStarted": "Refreshing metadata in the background",
"note": "NOTE",
"transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.",

View File

@ -20,8 +20,10 @@ import {
usePermissions,
useRecordContext,
} from 'react-admin'
import { Typography } from '@material-ui/core'
import { Title } from '../common'
import { Avatar, Typography } from '@material-ui/core'
import { Title, ImageUploadOverlay } from '../common'
import subsonic from '../subsonic'
import config from '../config'
import DeleteUserButton from './DeleteUserButton'
import { LibrarySelectionField } from './LibrarySelectionField.jsx'
import { validateUserForm } from './userValidation'
@ -33,6 +35,17 @@ const useStyles = makeStyles({
},
})
const useAvatarStyles = makeStyles({
avatarParent: {
display: 'inline-flex',
position: 'relative',
width: '8rem',
height: '8rem',
marginBottom: '1em',
},
avatar: { width: '100%', height: '100%' },
})
const UserTitle = ({ record }) => {
const translate = useTranslate()
const resourceName = translate('resources.user.name', { smart_count: 1 })
@ -65,6 +78,46 @@ const NewPasswordInput = ({ formData, ...rest }) => {
) : null
}
const AvatarField = () => {
const record = useRecordContext()
const { permissions } = usePermissions()
const isAdmin = permissions === 'admin'
const isMyself = localStorage.getItem('userId') === record?.id
// Mirrors server canEditAvatar: the flag gates everyone, admins included.
const canEdit = config.enableUserAvatarUpload && (isAdmin || isMyself)
const classes = useAvatarStyles()
if (!record?.id) return null
return (
<div className={classes.avatarParent}>
<Avatar
className={classes.avatar}
src={
record.uploadedImage
? `${subsonic.getAvatarUrl(record.userName)}&_=${record.updatedAt}`
: undefined
}
alt={record.name}
/>
<ImageUploadOverlay
entityType="user"
entityId={record.id}
hasUploadedImage={!!record.uploadedImage}
canEdit={canEdit}
messages={{
uploaded: 'message.avatarUploaded',
uploadError: 'message.avatarUploadError',
removed: 'message.avatarRemoved',
removeError: 'message.avatarRemoveError',
uploadLabel: 'message.uploadAvatar',
removeLabel: 'message.removeAvatar',
}}
/>
</div>
)
}
const UserEdit = (props) => {
const { permissions } = props
const translate = useTranslate()
@ -118,6 +171,7 @@ const UserEdit = (props) => {
save={save}
validate={validateForm}
>
<AvatarField />
{permissions === 'admin' && (
<TextInput
spellCheck={false}

View File

@ -1,7 +1,8 @@
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import UserEdit from './UserEdit'
import { describe, it, expect, vi } from 'vitest'
import config from '../config'
import { describe, it, expect, vi, afterEach } from 'vitest'
const defaultUser = {
id: 'user1',
@ -33,6 +34,8 @@ const hooks = vi.hoisted(() => ({
notify: vi.fn(),
redirect: vi.fn(),
refresh: vi.fn(),
permissions: 'admin',
record: null,
}))
// Mock React-Admin completely with simpler implementations
@ -67,7 +70,8 @@ vi.mock('react-admin', () => ({
useNotify: () => hooks.notify,
useRedirect: () => hooks.redirect,
useRefresh: () => hooks.refresh,
usePermissions: () => ({ permissions: 'admin' }),
usePermissions: () => ({ permissions: hooks.permissions }),
useRecordContext: () => hooks.record,
useTranslate: () => (key) => key,
}))
@ -82,6 +86,14 @@ vi.mock('./DeleteUserButton', () => ({
vi.mock('../common', () => ({
Title: ({ subTitle }) => <div data-testid="title">{subTitle}</div>,
ImageUploadOverlay: ({ canEdit, messages }) =>
canEdit ? <button aria-label={messages.uploadLabel}>upload</button> : null,
}))
vi.mock('../subsonic', () => ({
default: {
getAvatarUrl: (username) => `/rest/getAvatar?username=${username}`,
},
}))
// Mock Material-UI
@ -91,6 +103,7 @@ vi.mock('@material-ui/core/styles', () => ({
vi.mock('@material-ui/core', () => ({
Typography: ({ children }) => <p>{children}</p>,
Avatar: ({ src, alt }) => <img data-testid="avatar" src={src} alt={alt} />,
}))
describe('<UserEdit />', () => {
@ -198,4 +211,39 @@ describe('<UserEdit />', () => {
expect(hooks.redirect).not.toHaveBeenCalled()
})
})
describe('avatar upload', () => {
afterEach(() => {
localStorage.clear()
hooks.record = null
hooks.permissions = 'admin'
})
const renderUserEdit = (
record,
{ isMyself = false, role = 'user' } = {},
) => {
localStorage.setItem('userId', isMyself ? record.id : 'someone-else')
hooks.record = record
hooks.permissions = role
return render(<UserEdit id={record.id} permissions={role} />)
}
it('shows the avatar upload control for the user themselves', () => {
config.enableUserAvatarUpload = true
renderUserEdit({ id: 'u1', userName: 'deluan' }, { isMyself: true })
expect(screen.getByLabelText('message.uploadAvatar')).toBeInTheDocument()
})
it('hides the control when the feature is off and the viewer is not an admin', () => {
config.enableUserAvatarUpload = false
renderUserEdit(
{ id: 'u1', userName: 'deluan' },
{ isMyself: true, role: 'regular' },
)
expect(
screen.queryByLabelText('message.uploadAvatar'),
).not.toBeInTheDocument()
})
})
})