diff --git a/ui/src/common/SelectLibraryInput.jsx b/ui/src/common/SelectLibraryInput.jsx
new file mode 100644
index 000000000..4d34fd822
--- /dev/null
+++ b/ui/src/common/SelectLibraryInput.jsx
@@ -0,0 +1,141 @@
+import React, { useState, useEffect } from 'react'
+import Checkbox from '@material-ui/core/Checkbox'
+import CheckBoxIcon from '@material-ui/icons/CheckBox'
+import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
+import {
+ List,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+ Typography,
+} from '@material-ui/core'
+import { useGetList } from 'react-admin'
+import PropTypes from 'prop-types'
+import { makeStyles } from '@material-ui/core'
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ width: '960px', // MD breakpoint width
+ maxWidth: '100%',
+ },
+ libraryList: {
+ height: '120px', // 3 rows of libraries
+ overflow: 'auto',
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadius,
+ backgroundColor: theme.palette.background.paper,
+ },
+ listItem: {
+ paddingTop: 0,
+ paddingBottom: 0,
+ },
+ emptyMessage: {
+ padding: theme.spacing(2),
+ textAlign: 'center',
+ color: theme.palette.text.secondary,
+ },
+}))
+
+const EmptyLibraryMessage = () => {
+ const classes = useStyles()
+
+ return (
+
+ No libraries available
+
+ )
+}
+
+const LibraryListItem = ({ library, isSelected, onToggle }) => {
+ const classes = useStyles()
+
+ return (
+ onToggle(library)}
+ dense
+ >
+
+ }
+ checkedIcon={}
+ checked={isSelected}
+ tabIndex={-1}
+ disableRipple
+ />
+
+
+
+ )
+}
+
+export const SelectLibraryInput = ({ onChange, value = [] }) => {
+ const classes = useStyles()
+ const [selectedLibraryIds, setSelectedLibraryIds] = useState([])
+
+ const { ids, data } = useGetList(
+ 'library',
+ { page: 1, perPage: -1 },
+ { field: 'name', order: 'ASC' },
+ { smart: false },
+ )
+
+ const options = (ids && ids.map((id) => data[id])) || []
+
+ // Update selectedLibraryIds when value prop changes (for editing mode)
+ useEffect(() => {
+ if (Array.isArray(value)) {
+ const libraryIds = value.map((item) =>
+ typeof item === 'object' ? item.id : item,
+ )
+ setSelectedLibraryIds(libraryIds)
+ }
+ }, [value])
+
+ const isLibrarySelected = (library) => selectedLibraryIds.includes(library.id)
+
+ const handleLibraryToggle = (library) => {
+ const isSelected = selectedLibraryIds.includes(library.id)
+ let newSelection
+
+ if (isSelected) {
+ newSelection = selectedLibraryIds.filter((id) => id !== library.id)
+ } else {
+ newSelection = [...selectedLibraryIds, library.id]
+ }
+
+ setSelectedLibraryIds(newSelection)
+ onChange(newSelection)
+ }
+
+ return (
+
+
+ {options.length === 0 ? (
+
+ ) : (
+ options.map((library) => (
+
+ ))
+ )}
+
+
+ )
+}
+
+SelectLibraryInput.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.array,
+}
+
+LibraryListItem.propTypes = {
+ library: PropTypes.object.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onToggle: PropTypes.func.isRequired,
+}
diff --git a/ui/src/common/SelectLibraryInput.test.jsx b/ui/src/common/SelectLibraryInput.test.jsx
new file mode 100644
index 000000000..706ece428
--- /dev/null
+++ b/ui/src/common/SelectLibraryInput.test.jsx
@@ -0,0 +1,122 @@
+import * as React from 'react'
+import { render, screen, fireEvent, cleanup } from '@testing-library/react'
+import { SelectLibraryInput } from './SelectLibraryInput'
+import { useGetList } from 'react-admin'
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+
+// Mock the react-admin hook
+vi.mock('react-admin', () => ({
+ useGetList: vi.fn(),
+}))
+
+describe('', () => {
+ const mockOnChange = vi.fn()
+
+ beforeEach(() => {
+ // Reset the mock before each test
+ mockOnChange.mockClear()
+ })
+
+ afterEach(cleanup)
+
+ it('should render empty message when no libraries available', () => {
+ // Mock empty library response
+ useGetList.mockReturnValue({
+ ids: [],
+ data: {},
+ })
+
+ render()
+ expect(screen.getByText('No libraries available')).not.toBeNull()
+ })
+
+ it('should render libraries when available', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ render()
+ expect(screen.getByText('Library 1')).not.toBeNull()
+ expect(screen.getByText('Library 2')).not.toBeNull()
+ })
+
+ it('should toggle selection when a library is clicked', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+
+ // Test selecting an item
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+ render()
+
+ // First test - click to select
+ fireEvent.click(screen.getAllByRole('button')[0]) // Click first list item
+ expect(mockOnChange).toHaveBeenCalledWith(['1'])
+
+ // Clean up to avoid DOM duplication
+ cleanup()
+ mockOnChange.mockClear()
+
+ // Test deselecting an item
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+ render()
+
+ // Second test - click to deselect
+ fireEvent.click(screen.getAllByRole('button')[0]) // Click first list item again
+ expect(mockOnChange).toHaveBeenCalledWith([])
+ })
+
+ it('should correctly initialize with provided values', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ // Initial value as array of IDs
+ render()
+
+ // Check that checkbox for Library 1 is checked
+ const checkboxes = screen.getAllByRole('checkbox')
+ expect(checkboxes[0].checked).toBe(true)
+ expect(checkboxes[1].checked).toBe(false)
+ })
+
+ it('should handle value as array of objects', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ // Initial value as array of objects with id property
+ render()
+
+ // Check that checkbox for Library 2 is checked
+ const checkboxes = screen.getAllByRole('checkbox')
+ expect(checkboxes[0].checked).toBe(false)
+ expect(checkboxes[1].checked).toBe(true)
+ })
+})
diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js
index 257a274e8..c0244f780 100644
--- a/ui/src/dataProvider/wrapperDataProvider.js
+++ b/ui/src/dataProvider/wrapperDataProvider.js
@@ -43,6 +43,60 @@ const callDeleteMany = (resource, params) => {
}).then((response) => ({ data: response.json.ids || [] }))
}
+// Helper function to handle user-library associations
+const handleUserLibraryAssociation = async (userId, libraryIds) => {
+ if (!libraryIds || libraryIds.length === 0) {
+ return // Admin users or users without library assignments
+ }
+
+ try {
+ await httpClient(`${REST_URL}/user/${userId}/library`, {
+ method: 'PUT',
+ body: JSON.stringify({ libraryIds }),
+ })
+ } catch (error) {
+ console.error('Error setting user libraries:', error) //eslint-disable-line no-console
+ throw error
+ }
+}
+
+// Enhanced user creation that handles library associations
+const createUser = async (params) => {
+ const { data } = params
+ const { libraryIds, ...userData } = data
+
+ // First create the user
+ const userResponse = await dataProvider.create('user', { data: userData })
+ const userId = userResponse.data.id
+
+ // Then set library associations for non-admin users
+ if (!userData.isAdmin && libraryIds && libraryIds.length > 0) {
+ await handleUserLibraryAssociation(userId, libraryIds)
+ }
+
+ return userResponse
+}
+
+// Enhanced user update that handles library associations
+const updateUser = async (params) => {
+ const { data } = params
+ const { libraryIds, ...userData } = data
+ const userId = params.id
+
+ // First update the user
+ const userResponse = await dataProvider.update('user', {
+ ...params,
+ data: userData,
+ })
+
+ // Then handle library associations for non-admin users
+ if (!userData.isAdmin && libraryIds !== undefined) {
+ await handleUserLibraryAssociation(userId, libraryIds)
+ }
+
+ return userResponse
+}
+
const wrapperDataProvider = {
...dataProvider,
getList: (resource, params) => {
@@ -62,6 +116,9 @@ const wrapperDataProvider = {
return dataProvider.getManyReference(r, p)
},
update: (resource, params) => {
+ if (resource === 'user') {
+ return updateUser(params)
+ }
const [r, p] = mapResource(resource, params)
return dataProvider.update(r, p)
},
@@ -70,6 +127,9 @@ const wrapperDataProvider = {
return dataProvider.updateMany(r, p)
},
create: (resource, params) => {
+ if (resource === 'user') {
+ return createUser(params)
+ }
const [r, p] = mapResource(resource, params)
return dataProvider.create(r, p)
},
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 5693c11ed..3def7dcc5 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -149,19 +149,27 @@
"changePassword": "Change Password?",
"currentPassword": "Current Password",
"newPassword": "New Password",
- "token": "Token"
+ "token": "Token",
+ "libraries": "Libraries"
},
"helperTexts": {
- "name": "Changes to your name will only be reflected on next login"
+ "name": "Changes to your name will only be reflected on next login",
+ "libraries": "Select which libraries this user can access"
},
"notifications": {
"created": "User created",
"updated": "User updated",
"deleted": "User deleted"
},
+ "validation": {
+ "librariesRequired": "At least one library must be selected for non-admin users"
+ },
"message": {
"listenBrainzToken": "Enter your ListenBrainz user token.",
"clickHereForToken": "Click here to get your token"
+ },
+ "messages": {
+ "adminAutoLibraries": "Admin users automatically have access to all libraries"
}
},
"player": {
diff --git a/ui/src/user/LibrarySelectionField.jsx b/ui/src/user/LibrarySelectionField.jsx
new file mode 100644
index 000000000..b18d04951
--- /dev/null
+++ b/ui/src/user/LibrarySelectionField.jsx
@@ -0,0 +1,31 @@
+import { useInput, useTranslate } from 'react-admin'
+import { Box, FormControl, FormLabel, Typography } from '@material-ui/core'
+import { SelectLibraryInput } from '../common/SelectLibraryInput.jsx'
+import React from 'react'
+
+export const LibrarySelectionField = () => {
+ const translate = useTranslate()
+ const {
+ input: { name, onChange, value },
+ meta: { error, touched },
+ } = useInput({ source: 'libraryIds' })
+
+ return (
+
+
+ {translate('resources.user.fields.libraries')}
+
+
+
+
+ {touched && error && (
+
+ {error}
+
+ )}
+
+ {translate('resources.user.helperTexts.libraries')}
+
+
+ )
+}
diff --git a/ui/src/user/LibrarySelectionField.test.jsx b/ui/src/user/LibrarySelectionField.test.jsx
new file mode 100644
index 000000000..849c8ba17
--- /dev/null
+++ b/ui/src/user/LibrarySelectionField.test.jsx
@@ -0,0 +1,109 @@
+import * as React from 'react'
+import { render, screen, cleanup } from '@testing-library/react'
+import { LibrarySelectionField } from './LibrarySelectionField'
+import { useInput, useTranslate } from 'react-admin'
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { SelectLibraryInput } from '../common/SelectLibraryInput'
+
+// Mock the react-admin hooks
+vi.mock('react-admin', () => ({
+ useInput: vi.fn(),
+ useTranslate: vi.fn(),
+}))
+
+// Mock the SelectLibraryInput component
+vi.mock('../common/SelectLibraryInput.jsx', () => ({
+ SelectLibraryInput: vi.fn(() => ),
+}))
+
+describe('', () => {
+ const defaultProps = {
+ input: {
+ name: 'libraryIds',
+ value: [],
+ onChange: vi.fn(),
+ },
+ meta: {
+ touched: false,
+ error: undefined,
+ },
+ }
+
+ const mockTranslate = vi.fn((key) => key)
+
+ beforeEach(() => {
+ useInput.mockReturnValue(defaultProps)
+ useTranslate.mockReturnValue(mockTranslate)
+ SelectLibraryInput.mockClear()
+ })
+
+ afterEach(cleanup)
+
+ it('should render field label from translations', () => {
+ render()
+ expect(screen.getByText('resources.user.fields.libraries')).not.toBeNull()
+ })
+
+ it('should render helper text from translations', () => {
+ render()
+ expect(
+ screen.getByText('resources.user.helperTexts.libraries'),
+ ).not.toBeNull()
+ })
+
+ it('should render SelectLibraryInput with correct props', () => {
+ render()
+ expect(screen.getByTestId('select-library-input')).not.toBeNull()
+ expect(SelectLibraryInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ onChange: defaultProps.input.onChange,
+ value: defaultProps.input.value,
+ }),
+ expect.anything(),
+ )
+ })
+
+ it('should render error message when touched and has error', () => {
+ useInput.mockReturnValue({
+ ...defaultProps,
+ meta: {
+ touched: true,
+ error: 'This field is required',
+ },
+ })
+
+ render()
+ expect(screen.getByText('This field is required')).not.toBeNull()
+ })
+
+ it('should not render error message when not touched', () => {
+ useInput.mockReturnValue({
+ ...defaultProps,
+ meta: {
+ touched: false,
+ error: 'This field is required',
+ },
+ })
+
+ render()
+ expect(screen.queryByText('This field is required')).toBeNull()
+ })
+
+ it('should initialize with empty array when value is null', () => {
+ useInput.mockReturnValue({
+ ...defaultProps,
+ input: {
+ ...defaultProps.input,
+ value: null,
+ },
+ })
+
+ render()
+ expect(SelectLibraryInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: [],
+ }),
+ expect.anything(),
+ )
+ })
+})
diff --git a/ui/src/user/UserCreate.jsx b/ui/src/user/UserCreate.jsx
index 42ea1ce94..182f66ab5 100644
--- a/ui/src/user/UserCreate.jsx
+++ b/ui/src/user/UserCreate.jsx
@@ -2,17 +2,20 @@ import React, { useCallback } from 'react'
import {
BooleanInput,
Create,
- TextInput,
+ email,
+ FormDataConsumer,
PasswordInput,
required,
- email,
SimpleForm,
- useTranslate,
+ TextInput,
useMutation,
useNotify,
useRedirect,
+ useTranslate,
} from 'react-admin'
+import { Typography } from '@material-ui/core'
import { Title } from '../common'
+import { LibrarySelectionField } from './LibrarySelectionField.jsx'
const UserCreate = (props) => {
const translate = useTranslate()
@@ -48,9 +51,24 @@ const UserCreate = (props) => {
[mutate, notify, redirect],
)
+ // Custom validation function
+ const validateUserForm = (values) => {
+ const errors = {}
+ // Only require library selection for non-admin users
+ if (
+ !values.isAdmin &&
+ (!values.libraryIds || values.libraryIds.length === 0)
+ ) {
+ errors.libraryIds = translate(
+ 'resources.user.validation.librariesRequired',
+ )
+ }
+ return errors
+ }
+
return (
} {...props}>
-
+
{
validate={[required()]}
/>
+
+ {/* Conditional Library Selection */}
+
+ {({ formData }) => (
+ <>
+ {!formData.isAdmin && }
+
+ {formData.isAdmin && (
+
+ {translate('resources.user.messages.adminAutoLibraries')}
+
+ )}
+ >
+ )}
+
)
diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx
index 445f9c6fd..9401f8f7c 100644
--- a/ui/src/user/UserEdit.jsx
+++ b/ui/src/user/UserEdit.jsx
@@ -19,8 +19,10 @@ import {
FormDataConsumer,
usePermissions,
} from 'react-admin'
+import { Typography } from '@material-ui/core'
import { Title } from '../common'
import DeleteUserButton from './DeleteUserButton'
+import { LibrarySelectionField } from './LibrarySelectionField.jsx'
const useStyles = makeStyles({
toolbar: {
@@ -100,12 +102,28 @@ const UserEdit = (props) => {
[mutate, notify, permissions, redirect, refresh],
)
+ // Custom validation function
+ const validateUserForm = (values) => {
+ const errors = {}
+ // Only require library selection for non-admin users
+ if (
+ !values.isAdmin &&
+ (!values.libraryIds || values.libraryIds.length === 0)
+ ) {
+ errors.libraryIds = translate(
+ 'resources.user.validation.librariesRequired',
+ )
+ }
+ return errors
+ }
+
return (
} undoable={false} {...props}>
}
save={save}
+ validate={validateUserForm}
>
{permissions === 'admin' && (
{
{permissions === 'admin' && (
)}
+
+ {/* Conditional Library Selection for Admin Users Only */}
+ {permissions === 'admin' && (
+
+ {({ formData }) => (
+ <>
+ {!formData.isAdmin && }
+
+ {formData.isAdmin && (
+
+ {translate('resources.user.messages.adminAutoLibraries')}
+
+ )}
+ >
+ )}
+
+ )}
+