feat: add default library assignment for new users

Introduced a new column `default_new_users` in the library table to
facilitate automatic assignment of default libraries to new regular users.
When a new user is created, they will now be assigned to libraries marked
as default, enhancing user experience by ensuring they have immediate access
to essential resources. Additionally, updated the user repository logic
to handle this new functionality and modified the user creation validation
to reflect that library selection is optional for non-admin users.

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-06-30 14:32:00 -04:00
commit 7fbf6c6382
13 changed files with 322 additions and 30 deletions

View file

@ -36,6 +36,11 @@ func upAddUserLibraryTable(ctx context.Context, tx *sql.Tx) error {
UPDATE library SET total_duration = (
SELECT IFNULL(SUM(duration),0) from album where album.library_id = library.id and missing = 0
);
-- Add default_new_users column to library table
ALTER TABLE library ADD COLUMN default_new_users boolean DEFAULT false;
-- Set library ID 1 (default library) as default for new users
UPDATE library SET default_new_users = true WHERE id = 1;
`)
return err
@ -47,6 +52,7 @@ func downAddUserLibraryTable(ctx context.Context, tx *sql.Tx) error {
DROP INDEX IF EXISTS idx_user_library_user_id;
DROP TABLE IF EXISTS user_library;
ALTER TABLE library DROP COLUMN IF EXISTS total_duration;
ALTER TABLE library DROP COLUMN IF EXISTS default_new_users;
`)
return err
}

View file

@ -24,6 +24,7 @@ type Library struct {
TotalMissingFiles int `json:"totalMissingFiles" db:"total_missing_files"`
TotalSize int64 `json:"totalSize" db:"total_size"`
TotalDuration float64 `json:"totalDuration" db:"total_duration"`
DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"`
}
const (

View file

@ -90,10 +90,11 @@ func (r *libraryRepository) Put(l *model.Library) error {
} else {
// Try to update first
cols := map[string]any{
"name": l.Name,
"path": l.Path,
"remote_path": l.RemotePath,
"updated_at": time.Now(),
"name": l.Name,
"path": l.Path,
"remote_path": l.RemotePath,
"default_new_users": l.DefaultNewUsers,
"updated_at": time.Now(),
}
sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID})
rowsAffected, updateErr := r.executeSQL(sq)

View file

@ -148,6 +148,15 @@ func (r *userRepository) Put(u *model.User) error {
if _, err := r.executeSQL(sql); err != nil {
return fmt.Errorf("failed to assign all libraries to admin user: %w", err)
}
} else if count == 0 { // Only for new regular users
// Auto-assign default libraries to new regular users
sql := Expr(
"INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true",
u.ID,
)
if _, err := r.executeSQL(sql); err != nil {
return fmt.Errorf("failed to assign default libraries to new user: %w", err)
}
}
return nil

View file

@ -420,7 +420,7 @@ var _ = Describe("UserRepository", func() {
Expect(libIDs).To(ContainElements(library1.ID, library2.ID, 1))
})
It("does not affect regular users", func() {
It("assigns default libraries to regular users", func() {
regularUser := model.User{
ID: "regular-user-id-2",
UserName: "regularuser2",
@ -433,10 +433,12 @@ var _ = Describe("UserRepository", func() {
err := repo.Put(&regularUser)
Expect(err).To(BeNil())
// Regular user should have no library associations
// Regular user should be assigned to default libraries (library ID 1 from migration)
libraries, err := repo.GetUserLibraries(regularUser.ID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
Expect(libraries).To(HaveLen(1))
Expect(libraries[0].ID).To(Equal(1))
Expect(libraries[0].DefaultNewUsers).To(BeTrue())
})
})
@ -530,8 +532,8 @@ var _ = Describe("UserRepository", func() {
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
})
It("returns empty Libraries array when user has no library associations", func() {
// Create a user with no library associations
It("returns default Libraries array for new regular users", func() {
// Create a user with no explicit library associations - should get default libraries
userWithoutLibs := model.User{
ID: "no-libs-user",
UserName: "nolibsuser",
@ -548,7 +550,9 @@ var _ = Describe("UserRepository", func() {
user, err := repo.Get(userWithoutLibs.ID)
Expect(err).To(BeNil())
Expect(user.Libraries).ToNot(BeNil())
Expect(user.Libraries).To(HaveLen(0))
// Regular users should be assigned to default libraries (library ID 1 from migration)
Expect(user.Libraries).To(HaveLen(1))
Expect(user.Libraries[0].ID).To(Equal(1))
})
})
})

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import Checkbox from '@material-ui/core/Checkbox'
import CheckBoxIcon from '@material-ui/icons/CheckBox'
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
@ -82,29 +82,75 @@ const LibraryListItem = ({ library, isSelected, onToggle }) => {
)
}
export const SelectLibraryInput = ({ onChange, value = [] }) => {
export const SelectLibraryInput = ({
onChange,
value = [],
isNewUser = false,
}) => {
const classes = useStyles()
const translate = useTranslate()
const [selectedLibraryIds, setSelectedLibraryIds] = useState([])
const [hasInitialized, setHasInitialized] = useState(false)
const { ids, data } = useGetList(
const { ids, data, isLoading } = useGetList(
'library',
{ page: 1, perPage: -1 },
{ field: 'name', order: 'ASC' },
{ smart: false },
)
const options = (ids && ids.map((id) => data[id])) || []
const options = useMemo(
() => (ids && ids.map((id) => data[id])) || [],
[ids, data],
)
// Update selectedLibraryIds when value prop changes (for editing mode)
// Reset initialization state when isNewUser changes
useEffect(() => {
if (isNewUser) {
setHasInitialized(false)
}
}, [isNewUser])
// Pre-select default libraries for new users
useEffect(() => {
if (
isNewUser &&
!isLoading &&
options.length > 0 &&
!hasInitialized &&
Array.isArray(value) &&
value.length === 0
) {
const defaultLibraryIds = options
.filter((lib) => lib.defaultNewUsers)
.map((lib) => lib.id)
if (defaultLibraryIds.length > 0) {
setSelectedLibraryIds(defaultLibraryIds)
onChange(defaultLibraryIds)
}
setHasInitialized(true)
}
}, [isNewUser, isLoading, options, hasInitialized, value, onChange])
// Update selectedLibraryIds when value prop changes (for editing mode and pre-selection)
useEffect(() => {
// For new users, only sync from value prop if it has actual data
// This prevents empty initial state from overriding our pre-selection
if (isNewUser && Array.isArray(value) && value.length === 0) {
return
}
if (Array.isArray(value)) {
const libraryIds = value.map((item) =>
typeof item === 'object' ? item.id : item,
)
setSelectedLibraryIds(libraryIds)
} else if (value.length === 0) {
// Handle case where value is explicitly set to empty array (for existing users)
setSelectedLibraryIds([])
}
}, [value])
}, [value, isNewUser, hasInitialized])
const isLibrarySelected = (library) => selectedLibraryIds.includes(library.id)
@ -172,6 +218,7 @@ export const SelectLibraryInput = ({ onChange, value = [] }) => {
SelectLibraryInput.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.array,
isNewUser: PropTypes.bool,
}
LibraryListItem.propTypes = {

View file

@ -259,4 +259,200 @@ describe('<SelectLibraryInput />', () => {
// Note: Testing indeterminate property directly through JSDOM can be unreliable
// The important behavior is that it's not checked when only some are selected
})
describe('New User Default Library Selection', () => {
// Helper function to create mock libraries with configurable default settings
const createMockLibraries = (libraryConfigs) => {
const libraries = {}
const ids = []
libraryConfigs.forEach(({ id, name, defaultNewUsers }) => {
libraries[id] = {
id,
name,
...(defaultNewUsers !== undefined && { defaultNewUsers }),
}
ids.push(id)
})
return { libraries, ids }
}
// Helper function to setup useGetList mock
const setupMockLibraries = (libraryConfigs, isLoading = false) => {
const { libraries, ids } = createMockLibraries(libraryConfigs)
useGetList.mockReturnValue({
ids,
data: libraries,
isLoading,
})
return { libraries, ids }
}
beforeEach(() => {
mockOnChange.mockClear()
})
it('should pre-select default libraries for new users', () => {
setupMockLibraries([
{ id: '1', name: 'Library 1', defaultNewUsers: true },
{ id: '2', name: 'Library 2', defaultNewUsers: false },
{ id: '3', name: 'Library 3', defaultNewUsers: true },
])
render(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
expect(mockOnChange).toHaveBeenCalledWith(['1', '3'])
})
it('should not pre-select default libraries if new user already has values', () => {
setupMockLibraries([
{ id: '1', name: 'Library 1', defaultNewUsers: true },
{ id: '2', name: 'Library 2', defaultNewUsers: false },
])
render(
<SelectLibraryInput
onChange={mockOnChange}
value={['2']} // Already has a value
isNewUser={true}
/>,
)
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should not pre-select libraries while data is still loading', () => {
setupMockLibraries(
[{ id: '1', name: 'Library 1', defaultNewUsers: true }],
true,
) // isLoading = true
render(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should not pre-select anything if no libraries have defaultNewUsers flag', () => {
setupMockLibraries([
{ id: '1', name: 'Library 1', defaultNewUsers: false },
{ id: '2', name: 'Library 2', defaultNewUsers: false },
])
render(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should reset initialization state when isNewUser prop changes', () => {
setupMockLibraries([
{ id: '1', name: 'Library 1', defaultNewUsers: true },
])
const { rerender } = render(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={false} // Start as existing user
/>,
)
expect(mockOnChange).not.toHaveBeenCalled()
// Change to new user
rerender(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
expect(mockOnChange).toHaveBeenCalledWith(['1'])
})
it('should not override pre-selection when value prop is empty for new users', () => {
setupMockLibraries([
{ id: '1', name: 'Library 1', defaultNewUsers: true },
{ id: '2', name: 'Library 2', defaultNewUsers: false },
])
const { rerender } = render(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
expect(mockOnChange).toHaveBeenCalledWith(['1'])
mockOnChange.mockClear()
// Re-render with empty value prop (simulating form state update)
rerender(
<SelectLibraryInput
onChange={mockOnChange}
value={[]} // Still empty
isNewUser={true}
/>,
)
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should sync from value prop for existing users even when empty', () => {
setupMockLibraries([
{ id: '1', name: 'Library 1', defaultNewUsers: true },
])
render(
<SelectLibraryInput
onChange={mockOnChange}
value={[]} // Empty value for existing user
isNewUser={false}
/>,
)
// Check that no libraries are selected (checkboxes should be unchecked)
const checkboxes = screen.getAllByRole('checkbox')
// Only one checkbox since there's only one library and no master checkbox for single library
expect(checkboxes[0].checked).toBe(false)
})
it('should handle libraries with missing defaultNewUsers property', () => {
setupMockLibraries([
{ id: '1', name: 'Library 1', defaultNewUsers: true },
{ id: '2', name: 'Library 2' }, // Missing defaultNewUsers property
{ id: '3', name: 'Library 3', defaultNewUsers: false },
])
render(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
expect(mockOnChange).toHaveBeenCalledWith(['1'])
})
})
})

View file

@ -154,7 +154,7 @@
},
"helperTexts": {
"name": "Changes to your name will only be reflected on next login",
"libraries": "Select which libraries this user can access"
"libraries": "Select specific libraries for this user, or leave empty to use default libraries"
},
"notifications": {
"created": "User created",
@ -296,6 +296,7 @@
"totalMissingFiles": "Missing Files",
"totalSize": "Total Size",
"totalDuration": "Duration",
"defaultNewUsers": "Default for New Users",
"createdAt": "Created",
"updatedAt": "Updated"
},

View file

@ -3,6 +3,7 @@ import {
Create,
SimpleForm,
TextInput,
BooleanInput,
required,
useTranslate,
useMutation,
@ -74,6 +75,7 @@ const LibraryCreate = (props) => {
<SimpleForm save={save} variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<TextInput source="path" validate={[required()]} fullWidth />
<BooleanInput source="defaultNewUsers" />
</SimpleForm>
</Create>
)

View file

@ -3,6 +3,7 @@ import {
Edit,
FormWithRedirect,
TextInput,
BooleanInput,
required,
SaveButton,
DateField,
@ -98,16 +99,25 @@ const LibraryEdit = (props) => {
<TextInput
source="name"
label={translate('resources.library.fields.name')}
validate={[required()]}
variant="outlined"
/>
<TextInput
source="path"
label={translate('resources.library.fields.path')}
validate={[required()]}
fullWidth
variant="outlined"
InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1
/>
<BooleanInput
source="defaultNewUsers"
label={translate(
'resources.library.fields.defaultNewUsers',
)}
variant="outlined"
/>
<Box mt="2em" />
@ -122,6 +132,7 @@ const LibraryEdit = (props) => {
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalSongs'}
label={translate('resources.library.fields.totalSongs')}
fullWidth
variant="outlined"
/>
@ -131,6 +142,9 @@ const LibraryEdit = (props) => {
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalAlbums'}
label={translate(
'resources.library.fields.totalAlbums',
)}
fullWidth
variant="outlined"
/>
@ -143,6 +157,9 @@ const LibraryEdit = (props) => {
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalArtists'}
label={translate(
'resources.library.fields.totalArtists',
)}
fullWidth
variant="outlined"
/>
@ -152,6 +169,7 @@ const LibraryEdit = (props) => {
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalSize'}
label={translate('resources.library.fields.totalSize')}
format={formatBytes}
fullWidth
variant="outlined"
@ -165,6 +183,9 @@ const LibraryEdit = (props) => {
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalDuration'}
label={translate(
'resources.library.fields.totalDuration',
)}
format={formatDuration2}
fullWidth
variant="outlined"
@ -175,6 +196,9 @@ const LibraryEdit = (props) => {
InputProps={{ readOnly: true }}
resource={'library'}
source={'totalMissingFiles'}
label={translate(
'resources.library.fields.totalMissingFiles',
)}
fullWidth
variant="outlined"
/>

View file

@ -6,6 +6,7 @@ import {
SimpleList,
TextField,
NumberField,
BooleanField,
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { List, DateField, useResourceRefresh } from '../common'
@ -35,9 +36,9 @@ const LibraryList = (props) => {
/>
) : (
<Datagrid rowClick="edit">
<TextField source="id" />
<TextField source="name" />
<TextField source="path" />
<BooleanField source="defaultNewUsers" />
<NumberField source="totalSongs" label="Songs" />
<NumberField source="totalAlbums" label="Albums" />
<NumberField source="totalMissingFiles" label="Missing Files" />

View file

@ -27,13 +27,20 @@ export const LibrarySelectionField = () => {
return []
}, [value, record])
// Determine if this is a new user (no ID means new record)
const isNewUser = !record?.id
return (
<FormControl error={!!(touched && error)} fullWidth margin="normal">
<FormLabel component="legend" required>
<FormLabel component="legend">
{translate('resources.user.fields.libraries')}
</FormLabel>
<Box mt={1} mb={1}>
<SelectLibraryInput onChange={onChange} value={libraryIds} />
<SelectLibraryInput
onChange={onChange}
value={libraryIds}
isNewUser={isNewUser}
/>
</Box>
{touched && error && (
<Typography color="error" variant="caption">

View file

@ -54,15 +54,8 @@ const UserCreate = (props) => {
// 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',
)
}
// Library selection is optional for non-admin users since they will be auto-assigned to default libraries
// No validation required for library selection
return errors
}