mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-31 23:47:47 +01:00
feat(ui): add avatar upload to the user edit page
This commit is contained in:
parent
89d8fbbd13
commit
e405723c43
3 changed files with 112 additions and 4 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue