diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index de96d47c0..6c23f393b 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -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.", diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index c5d9c75a4..b6523bc2b 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -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 ( +
+ + +
+ ) +} + const UserEdit = (props) => { const { permissions } = props const translate = useTranslate() @@ -118,6 +171,7 @@ const UserEdit = (props) => { save={save} validate={validateForm} > + {permissions === 'admin' && ( ({ 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 }) =>
{subTitle}
, + ImageUploadOverlay: ({ canEdit, messages }) => + canEdit ? : 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 }) =>

{children}

, + Avatar: ({ src, alt }) => {alt}, })) describe('', () => { @@ -198,4 +211,39 @@ describe('', () => { 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() + } + + 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() + }) + }) })