Patch: Further multi-user fixes (#613)

This commit is contained in:
Alex
2026-02-12 17:47:10 +00:00
committed by GitHub
parent a7064939ce
commit af9d9ec8db
30 changed files with 833 additions and 325 deletions
@@ -17,7 +17,7 @@ import {
ShowWhenCondition,
TableFieldConfig,
} from '../../types/settings';
import { FieldWrapper } from './shared';
import { FieldWrapper, SettingsSaveBar } from './shared';
import {
TextField,
PasswordField,
@@ -351,14 +351,7 @@ export const SettingsContent = ({
</div>
{/* Save button - only visible when there are changes */}
{hasChanges && (
<div
className="flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
{saveButton}
</div>
)}
{hasChanges && <SettingsSaveBar onSave={onSave} isSaving={isSaving} />}
</div>
);
};
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useSettings } from '../../hooks/useSettings';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { getAdminSettingsOverridesSummary } from '../../services/api';
import { getAdminSettingsOverridesSummary, getSettingsTab } from '../../services/api';
import { SettingsHeader } from './SettingsHeader';
import { SettingsSidebar } from './SettingsSidebar';
import { SettingsContent } from './SettingsContent';
@@ -37,7 +37,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
const [isMobile, setIsMobile] = useState(false);
const [showMobileDetail, setShowMobileDetail] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [usersSubpageState, setUsersSubpageState] = useState<{ title: string; onBack: () => void } | null>(null);
const [securityAccessError, setSecurityAccessError] = useState<string | null>(null);
const [tabOverrideSummaries, setTabOverrideSummaries] = useState<
Record<string, Record<string, { count: number; users: Array<{ userId: number; username: string; value: unknown }> }>>
>({});
@@ -97,16 +97,36 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
if (isOpen) {
setShowMobileDetail(false);
setIsClosing(false);
setUsersSubpageState(null);
setTabOverrideSummaries({});
}
}, [isOpen]);
useEffect(() => {
if (selectedTab !== 'users') {
setUsersSubpageState(null);
if (!isOpen || selectedTab !== 'security') {
setSecurityAccessError(null);
return;
}
}, [selectedTab]);
let cancelled = false;
getSettingsTab("security")
.then(() => {
if (cancelled) return;
setSecurityAccessError(null);
})
.catch((err) => {
if (cancelled) return;
const message = err instanceof Error ? err.message : 'Failed to load security settings';
if (message.toLowerCase().includes('admin access required')) {
setSecurityAccessError(message);
return;
}
setSecurityAccessError(null);
});
return () => {
cancelled = true;
};
}, [isOpen, selectedTab]);
useEffect(() => {
if (!isOpen || !selectedTab || selectedTab === 'users') {
@@ -243,7 +263,6 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
const currentTab = tabs.find((t) => t.name === selectedTab);
const currentTabDisplayName = currentTab?.displayName || 'Settings';
const usersHeaderTitle = usersSubpageState ? `Settings / ${usersSubpageState.title}` : null;
const selectedAuthMethod = values.security?.AUTH_METHOD;
const usersAuthMode = typeof selectedAuthMethod === 'string' ? selectedAuthMethod : authMode;
const currentTabContent = currentTab
@@ -258,8 +277,11 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
isSaving={isSaving}
hasChanges={currentTabHasChanges}
onShowToast={onShowToast}
onSubpageStateChange={setUsersSubpageState}
/>
) : (selectedTab === 'security' && securityAccessError) ? (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-3">
<p className="text-sm opacity-60">{securityAccessError}</p>
</div>
) : (
<SettingsContent
tab={currentTab}
@@ -381,9 +403,9 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
// Detail view
<>
<SettingsHeader
title={selectedTab === 'users' && usersHeaderTitle ? usersHeaderTitle : currentTabDisplayName}
title={currentTabDisplayName}
showBack
onBack={selectedTab === 'users' && usersSubpageState ? usersSubpageState.onBack : handleBack}
onBack={handleBack}
onClose={handleClose}
/>
{currentTabContent}
@@ -416,9 +438,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
aria-label="Settings"
>
<SettingsHeader
title={selectedTab === 'users' && usersHeaderTitle ? usersHeaderTitle : 'Settings'}
showBack={selectedTab === 'users' && !!usersSubpageState}
onBack={selectedTab === 'users' && usersSubpageState ? usersSubpageState.onBack : undefined}
title="Settings"
onClose={handleClose}
/>
@@ -11,7 +11,7 @@ import {
useUsersPanelState,
} from './users';
import { SettingsContent } from './SettingsContent';
import { SettingsSubpage } from './shared';
import { SettingsSaveBar } from './shared';
interface UsersPanelProps {
authMode: string;
@@ -23,7 +23,6 @@ interface UsersPanelProps {
isSaving: boolean;
hasChanges: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onSubpageStateChange?: (state: { title: string; onBack: () => void } | null) => void;
}
export const UsersPanel = ({
@@ -36,7 +35,6 @@ export const UsersPanel = ({
isSaving,
hasChanges,
onShowToast,
onSubpageStateChange,
}: UsersPanelProps) => {
const { route, openCreate, openEdit, openEditOverrides, backToList } = useUsersPanelState();
@@ -63,6 +61,7 @@ export const UsersPanel = ({
isUserOverridable,
userSettings,
setUserSettings,
hasUserSettingsChanges,
beginEditing,
applyUserEditContext,
resetEditContext,
@@ -82,6 +81,7 @@ export const UsersPanel = ({
} = useUserMutations({
onShowToast,
fetchUsers,
users,
createForm,
resetCreateForm,
editingUser,
@@ -145,36 +145,25 @@ export const UsersPanel = ({
backToList();
};
useEffect(() => {
if (!onSubpageStateChange) {
return undefined;
}
if (route.kind === 'edit-overrides') {
const username = editingUser && editingUser.id === route.userId
? editingUser.username
: 'User';
onSubpageStateChange({
title: `Users / User Preferences: ${username}`,
onBack: handleBackToEdit,
});
} else {
onSubpageStateChange(null);
}
return () => {
onSubpageStateChange(null);
};
}, [editingUser, handleBackToEdit, onSubpageStateChange, route]);
useEffect(() => {
if (route.kind === 'create' && !canCreateLocalUsers) {
backToList();
}
}, [backToList, canCreateLocalUsers, route.kind]);
const handleSave = async () => {
const ok = await saveEditedUser();
const handleSaveUserEdit = async () => {
const ok = await saveEditedUser({ includeSettings: false });
if (ok) {
backToList();
}
};
const handleSaveUserOverrides = async () => {
const ok = await saveEditedUser({
includeProfile: false,
includePassword: false,
includeSettings: true,
});
if (ok) {
backToList();
}
@@ -213,65 +202,79 @@ export const UsersPanel = ({
}
return (
<UserOverridesView
onSave={handleSave}
saving={saving}
onBack={handleBackToEdit}
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={(updater) => setUserSettings(updater)}
/>
<div className="flex-1 flex flex-col min-h-0">
<UserOverridesView
hasChanges={hasUserSettingsChanges}
onBack={handleBackToEdit}
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={(updater) => setUserSettings(updater)}
/>
{hasUserSettingsChanges && (
<SettingsSaveBar onSave={handleSaveUserOverrides} isSaving={saving} />
)}
</div>
);
}
return (
<SettingsSubpage>
<div>
<UserListView
authMode={authMode}
users={users}
onCreate={openCreate}
showCreateForm={route.kind === 'create'}
createForm={createForm}
onCreateFormChange={setCreateForm}
creating={creating}
isFirstUser={users.length === 0}
onCreateSubmit={handleCreate}
onCancelCreate={handleCancelCreate}
showEditForm={route.kind === 'edit'}
activeEditUserId={route.kind === 'edit' ? route.userId : null}
editingUser={route.kind === 'edit' ? editingUser : null}
onEditingUserChange={setEditingUser}
onEditSave={handleSave}
saving={saving}
onCancelEdit={handleBackToList}
editPassword={editPassword}
onEditPasswordChange={setEditPassword}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={setEditPasswordConfirm}
downloadDefaults={downloadDefaults}
onOpenOverrides={handleOpenOverrides}
onEdit={handleEdit}
onDelete={deleteUser}
deletingUserId={deletingUserId}
onSyncCwa={handleSyncCwa}
syncingCwa={syncingCwa}
/>
<div className="pt-5 mt-4 border-t border-black/10 dark:border-white/10">
<SettingsContent
tab={tab}
values={values}
onChange={onChange}
onSave={onSave}
onAction={onAction}
isSaving={isSaving}
hasChanges={hasChanges}
embedded
<div className="flex-1 flex flex-col min-h-0">
<div
className="flex-1 overflow-y-auto p-6"
style={{ paddingBottom: hasChanges ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
>
<div>
<UserListView
authMode={authMode}
users={users}
onCreate={openCreate}
showCreateForm={route.kind === 'create'}
createForm={createForm}
onCreateFormChange={setCreateForm}
creating={creating}
isFirstUser={users.length === 0}
onCreateSubmit={handleCreate}
onCancelCreate={handleCancelCreate}
showEditForm={route.kind === 'edit'}
activeEditUserId={route.kind === 'edit' ? route.userId : null}
editingUser={route.kind === 'edit' ? editingUser : null}
onEditingUserChange={setEditingUser}
onEditSave={handleSaveUserEdit}
saving={saving}
onCancelEdit={handleBackToList}
editPassword={editPassword}
onEditPasswordChange={setEditPassword}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={setEditPasswordConfirm}
downloadDefaults={downloadDefaults}
onOpenOverrides={handleOpenOverrides}
onEdit={handleEdit}
onDelete={deleteUser}
deletingUserId={deletingUserId}
onSyncCwa={handleSyncCwa}
syncingCwa={syncingCwa}
/>
<div className="pt-5 mt-4 border-t border-black/10 dark:border-white/10">
<SettingsContent
tab={tab}
values={values}
onChange={onChange}
onSave={onSave}
onAction={onAction}
isSaving={isSaving}
hasChanges={false}
embedded
/>
</div>
</div>
</div>
</SettingsSubpage>
{hasChanges && (
<SettingsSaveBar onSave={onSave} isSaving={isSaving} />
)}
</div>
);
};
@@ -0,0 +1,43 @@
interface SettingsSaveBarProps {
onSave: () => void | Promise<void>;
isSaving: boolean;
}
export const SettingsSaveBar = ({ onSave, isSaving }: SettingsSaveBarProps) => (
<div
className="flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button
onClick={() => { void onSave(); }}
disabled={isSaving}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Saving...
</span>
) : (
'Save Changes'
)}
</button>
</div>
);
@@ -2,13 +2,18 @@ import { ReactNode } from 'react';
interface SettingsSubpageProps {
children: ReactNode;
hasBottomSaveBar?: boolean;
}
export const SettingsSubpage = ({
children,
hasBottomSaveBar = false,
}: SettingsSubpageProps) => {
return (
<div className="flex-1 overflow-y-auto p-6">
<div
className="flex-1 overflow-y-auto p-6"
style={{ paddingBottom: hasBottomSaveBar ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
>
{children}
</div>
);
@@ -1,3 +1,4 @@
export { EnvLockBadge } from './EnvLockBadge';
export { FieldWrapper } from './FieldWrapper';
export { SettingsSaveBar } from './SettingsSaveBar';
export { SettingsSubpage } from './SettingsSubpage';
@@ -165,7 +165,11 @@ interface UserEditFieldsProps {
editPasswordConfirm: string;
onEditPasswordConfirmChange: (value: string) => void;
downloadDefaults: DownloadDefaults | null;
onEditOverrides?: () => void;
onDelete?: () => void;
onConfirmDelete?: () => void;
onCancelDelete?: () => void;
isDeletePending?: boolean;
deleting?: boolean;
}
export const UserEditFields = ({
@@ -179,7 +183,11 @@ export const UserEditFields = ({
editPasswordConfirm,
onEditPasswordConfirmChange,
downloadDefaults,
onEditOverrides,
onDelete,
onConfirmDelete,
onCancelDelete,
isDeletePending = false,
deleting = false,
}: UserEditFieldsProps) => {
const capabilities = user.edit_capabilities;
const { authSource, canSetPassword, canEditRole, canEditEmail, canEditDisplayName } = capabilities;
@@ -246,29 +254,53 @@ export const UserEditFields = ({
</>
)}
<div className="flex flex-wrap gap-2 pt-2">
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
{onEditOverrides && (
<div className="flex flex-col gap-2 pt-3 border-t border-[var(--border-muted)] sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-2">
<button
onClick={onEditOverrides}
className="ml-auto px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
onClick={onSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
User Preferences
Cancel
</button>
</div>
{onDelete && (
<div className="flex flex-wrap gap-2 sm:ml-auto">
{isDeletePending ? (
<>
<button
onClick={onConfirmDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{deleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button
onClick={onCancelDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
</>
) : (
<button
onClick={onDelete}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
border border-red-500/40 text-red-600 hover:bg-red-500/10"
>
Delete User
</button>
)}
</div>
)}
</div>
</>
@@ -74,6 +74,7 @@ export const UserListView = ({
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode);
const isCwaMode = String(authMode || 'none').toLowerCase() === 'cwa';
const toggleButtonClasses = 'p-2 rounded-full hover-action transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100';
const usersHeading: HeadingFieldConfig = {
key: 'users_heading',
type: 'HeadingField',
@@ -113,7 +114,9 @@ export const UserListView = ({
key={user.id}
className={`rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] transition-colors ${active ? '' : 'opacity-60'}`}
>
<div className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 ${isEditingRow ? 'border-b border-[var(--border-muted)]' : ''}`}>
<div
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 ${isEditingRow ? 'border-b border-[var(--border-muted)]' : ''}`}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium shrink-0
@@ -150,72 +153,44 @@ export const UserListView = ({
{roleLabel}
</span>
{!isEditingRow && (
<>
<button
onClick={() => onEdit(user)}
className="p-2 rounded-full hover-action transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
aria-label={`Edit ${user.username}`}
title="Edit user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-[18px] h-[18px]"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
</button>
{confirmDelete === user.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => handleDelete(user.id)}
disabled={deletingUserId === user.id}
className="text-xs font-medium px-2.5 py-1.5 rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{deletingUserId === user.id ? 'Deleting...' : 'Confirm'}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs font-medium px-2.5 py-1.5 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(user.id)}
className="p-2 rounded-full hover-action transition-colors text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
aria-label={`Delete ${user.username}`}
title="Delete user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-[18px] h-[18px]"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
)}
</>
{isEditingRow && (
<button
onClick={onOpenOverrides}
className="px-4 py-2 rounded-lg text-sm font-medium text-white
bg-sky-600 hover:bg-sky-700 transition-colors"
>
User Preferences
</button>
)}
<button
onClick={() => {
setConfirmDelete(null);
if (isEditingRow) {
onCancelEdit();
} else {
onEdit(user);
}
}}
className={toggleButtonClasses}
aria-label={isEditingRow ? 'Collapse user editor' : `Expand ${user.username} editor`}
title={isEditingRow ? 'Collapse editor' : 'Expand editor'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`w-[18px] h-[18px] transition-transform duration-200 ${isEditingRow ? 'rotate-180' : ''}`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
</div>
</div>
@@ -233,7 +208,11 @@ export const UserListView = ({
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
downloadDefaults={downloadDefaults}
onEditOverrides={onOpenOverrides}
onDelete={() => setConfirmDelete(user.id)}
onConfirmDelete={() => handleDelete(user.id)}
onCancelDelete={() => setConfirmDelete(null)}
isDeletePending={confirmDelete === user.id}
deleting={deletingUserId === user.id}
/>
) : (
<div className="text-sm opacity-60">Loading user details...</div>
@@ -44,9 +44,8 @@ const fallbackDestinationAudiobookField: TextFieldConfig = {
type: 'TextField',
key: 'DESTINATION_AUDIOBOOK',
label: 'Destination',
description: 'Directory where downloaded audiobook files are saved. Use {User} for per-user folders and leave empty to use the books destination.',
description: 'Directory for this user\'s audiobook downloads.',
value: '',
placeholder: '/audiobooks',
};
const fallbackBookloreLibraryField: SelectFieldConfig = {
@@ -141,7 +140,7 @@ const audiobooksHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'delivery_preferences_audiobooks_heading',
title: 'Audiobooks',
description: 'Audiobooks always use folder output. Set a user-specific destination or leave empty to inherit books.',
description: 'Audiobooks always use folder output. Use Reset to inherit the global audiobook destination.',
};
const BOOK_PREFERENCE_KEYS: DeliverySettingKey[] = [
@@ -169,12 +168,26 @@ export const UserOverridesSection = ({
const destinationAudiobookField = getFieldByKey<TextFieldConfig>(fields, 'DESTINATION_AUDIOBOOK', fallbackDestinationAudiobookField);
const bookloreLibraryField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_LIBRARY_ID', fallbackBookloreLibraryField);
const booklorePathField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_PATH_ID', fallbackBooklorePathField);
const emailRecipientField = getFieldByKey<TextFieldConfig>(fields, 'EMAIL_RECIPIENT', fallbackEmailRecipientField);
const emailRecipientFieldSource = getFieldByKey<TextFieldConfig>(fields, 'EMAIL_RECIPIENT', fallbackEmailRecipientField);
const emailRecipientField: TextFieldConfig = {
...emailRecipientFieldSource,
label: 'Email Recipient',
description: 'Email address used for this user in Email output mode.',
};
const isOverridden = (key: DeliverySettingKey): boolean =>
Object.prototype.hasOwnProperty.call(userSettings, key) &&
userSettings[key] !== null &&
userSettings[key] !== undefined;
const isOverridden = (key: DeliverySettingKey): boolean => {
if (
!Object.prototype.hasOwnProperty.call(userSettings, key) ||
userSettings[key] === null ||
userSettings[key] === undefined
) {
return false;
}
const userValue = toStringValue(userSettings[key]);
const globalValue = toStringValue(globalValues[key as string]);
return userValue !== globalValue;
};
const resetKeys = (keys: DeliverySettingKey[]) => {
setUserSettings((prev) => {
@@ -242,24 +255,7 @@ export const UserOverridesSection = ({
<SelectField
field={outputModeField}
value={outputModeValue}
onChange={(value) => {
setUserSettings((prev) => {
const next: PerUserSettings = { ...prev, BOOKS_OUTPUT_MODE: value };
if (value === 'folder') {
delete next.BOOKLORE_LIBRARY_ID;
delete next.BOOKLORE_PATH_ID;
delete next.EMAIL_RECIPIENT;
} else if (value === 'booklore') {
delete next.DESTINATION;
delete next.EMAIL_RECIPIENT;
} else if (value === 'email') {
delete next.DESTINATION;
delete next.BOOKLORE_LIBRARY_ID;
delete next.BOOKLORE_PATH_ID;
}
return next;
});
}}
onChange={(value) => setUserSettings((prev) => ({ ...prev, BOOKS_OUTPUT_MODE: value }))}
disabled={Boolean(outputModeField.fromEnv)}
/>
</FieldWrapper>
@@ -370,12 +366,12 @@ export const UserOverridesSection = ({
) : undefined
}
>
<TextField
field={destinationAudiobookField}
value={destinationAudiobookValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, DESTINATION_AUDIOBOOK: value }))}
disabled={Boolean(destinationAudiobookField.fromEnv)}
/>
<TextField
field={destinationAudiobookField}
value={destinationAudiobookValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, DESTINATION_AUDIOBOOK: value }))}
disabled={Boolean(destinationAudiobookField.fromEnv)}
/>
</FieldWrapper>
</>
)}
@@ -4,8 +4,7 @@ import { SettingsSubpage } from '../shared';
import { UserOverridesSection } from './UserOverridesSection';
interface UserOverridesViewProps {
onSave: () => void;
saving: boolean;
hasChanges: boolean;
onBack: () => void;
deliveryPreferences: DeliveryPreferencesResponse | null;
isUserOverridable: (key: keyof PerUserSettings) => boolean;
@@ -14,39 +13,41 @@ interface UserOverridesViewProps {
}
export const UserOverridesView = ({
onSave,
saving,
hasChanges,
onBack,
deliveryPreferences,
isUserOverridable,
userSettings,
setUserSettings,
}: UserOverridesViewProps) => (
<SettingsSubpage>
<SettingsSubpage hasBottomSaveBar={hasChanges}>
<div className="space-y-5">
<div>
<button
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Back to User
</button>
</div>
<UserOverridesSection
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
/>
<div className="flex gap-2 pt-2">
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onBack}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
>
Back
</button>
</div>
</div>
</SettingsSubpage>
);
@@ -0,0 +1,38 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import { PerUserSettings } from './types';
const normalizeComparableValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '';
}
return String(value);
};
export const buildUserSettingsPayload = (
userSettings: PerUserSettings,
userOverridableSettings: Set<string>,
deliveryPreferences: DeliveryPreferencesResponse | null,
): Record<string, unknown> =>
(deliveryPreferences?.keys || [...userOverridableSettings])
.map(String)
.sort()
.reduce<Record<string, unknown>>((payload, key) => {
const typedKey = key as keyof PerUserSettings;
const hasUserValue = Object.prototype.hasOwnProperty.call(userSettings, typedKey)
&& userSettings[typedKey] !== null
&& userSettings[typedKey] !== undefined;
if (!hasUserValue) {
payload[key] = null;
return payload;
}
const userValue = userSettings[typedKey];
const globalValue = deliveryPreferences?.globalValues?.[key];
const isDifferentFromGlobal = deliveryPreferences
? normalizeComparableValue(userValue) !== normalizeComparableValue(globalValue)
: true;
payload[key] = isDifferentFromGlobal ? userValue : null;
return payload;
}, {});
@@ -2,6 +2,19 @@ import { useState } from 'react';
import { AdminUser, DeliveryPreferencesResponse, DownloadDefaults } from '../../../services/api';
import { CreateUserFormState, INITIAL_CREATE_FORM, PerUserSettings } from './types';
import { UserEditContext } from './useUsersFetch';
import { buildUserSettingsPayload } from './settingsPayload';
const normalizeUserSettings = (settings: PerUserSettings): PerUserSettings => {
const normalized: PerUserSettings = {};
Object.keys(settings).sort().forEach((key) => {
const typedKey = key as keyof PerUserSettings;
const value = settings[typedKey];
if (value !== null && value !== undefined) {
normalized[typedKey] = value;
}
});
return normalized;
};
export const useUserForm = () => {
const [createForm, setCreateForm] = useState<CreateUserFormState>({ ...INITIAL_CREATE_FORM });
@@ -11,6 +24,7 @@ export const useUserForm = () => {
const [downloadDefaults, setDownloadDefaults] = useState<DownloadDefaults | null>(null);
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [originalUserSettings, setOriginalUserSettings] = useState<PerUserSettings>({});
const [userOverridableSettings, setUserOverridableSettings] = useState<Set<string>>(new Set());
const resetCreateForm = () => setCreateForm({ ...INITIAL_CREATE_FORM });
@@ -19,6 +33,7 @@ export const useUserForm = () => {
setDownloadDefaults(null);
setDeliveryPreferences(null);
setUserSettings({});
setOriginalUserSettings({});
setUserOverridableSettings(new Set());
};
@@ -29,10 +44,12 @@ export const useUserForm = () => {
};
const applyUserEditContext = (context: UserEditContext) => {
const normalizedSettings = normalizeUserSettings(context.userSettings);
setEditingUser({ ...context.user });
setDownloadDefaults(context.downloadDefaults);
setDeliveryPreferences(context.deliveryPreferences);
setUserSettings(context.userSettings);
setUserSettings(normalizedSettings);
setOriginalUserSettings(normalizedSettings);
setUserOverridableSettings(new Set(context.userOverridableSettings));
};
@@ -44,6 +61,9 @@ export const useUserForm = () => {
};
const isUserOverridable = (key: keyof PerUserSettings) => userOverridableSettings.has(String(key));
const hasUserSettingsChanges =
JSON.stringify(buildUserSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences))
!== JSON.stringify(buildUserSettingsPayload(originalUserSettings, userOverridableSettings, deliveryPreferences));
return {
createForm,
@@ -63,6 +83,7 @@ export const useUserForm = () => {
deliveryPreferences,
userSettings,
setUserSettings,
hasUserSettingsChanges,
userOverridableSettings,
isUserOverridable,
};
@@ -8,11 +8,13 @@ import {
updateAdminUser,
} from '../../../services/api';
import { CreateUserFormState, PerUserSettings } from './types';
import { buildUserSettingsPayload } from './settingsPayload';
const MIN_PASSWORD_LENGTH = 4;
interface UseUserMutationsParams {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
fetchUsers: () => Promise<void>;
fetchUsers: () => Promise<AdminUser[]>;
users: AdminUser[];
createForm: CreateUserFormState;
resetCreateForm: () => void;
editingUser: AdminUser | null;
@@ -24,24 +26,32 @@ interface UseUserMutationsParams {
onEditSaveSuccess?: () => void;
}
interface SaveEditedUserOptions {
includeProfile?: boolean;
includePassword?: boolean;
includeSettings?: boolean;
}
const getPasswordError = (password: string, passwordConfirm: string) => {
if (!password) return null;
if (password.length < MIN_PASSWORD_LENGTH) return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
return password === passwordConfirm ? null : 'Passwords do not match';
};
const buildSettingsPayload = (userSettings: PerUserSettings, userOverridableSettings: Set<string>, deliveryPreferences: DeliveryPreferencesResponse | null) =>
(deliveryPreferences?.keys || [...userOverridableSettings]).reduce<Record<string, unknown>>((payload, key) => {
const typedKey = key as keyof PerUserSettings;
payload[key] = Object.prototype.hasOwnProperty.call(userSettings, typedKey) && userSettings[typedKey] !== null && userSettings[typedKey] !== undefined
? (userSettings[typedKey] ?? '')
: null;
return payload;
}, {});
const countLocalPasswordAdmins = (users: AdminUser[]): number =>
users.filter((user) => user.auth_source === 'builtin' && user.role === 'admin').length;
const authSourceLabel: Record<AdminUser['auth_source'], string> = {
builtin: 'Local',
oidc: 'OIDC',
proxy: 'Proxy',
cwa: 'CWA',
};
export const useUserMutations = ({
onShowToast,
fetchUsers,
users,
createForm,
resetCreateForm,
editingUser,
@@ -82,43 +92,91 @@ export const useUserMutations = ({
}
};
const saveEditedUser = async () => {
const saveEditedUser = async ({
includeProfile = true,
includePassword = true,
includeSettings = true,
}: SaveEditedUserOptions = {}) => {
if (!editingUser) return false;
const passwordError = getPasswordError(editPassword, editPasswordConfirm);
const passwordError = includePassword ? getPasswordError(editPassword, editPasswordConfirm) : null;
if (passwordError) return fail(passwordError);
const localAdminsBeforeSave = includeProfile ? countLocalPasswordAdmins(users) : 0;
const caps = editingUser.edit_capabilities;
const settingsPayload = buildSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences);
const settingsPayload = includeSettings
? buildUserSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences)
: null;
const updatePayload: Partial<Pick<AdminUser, 'role' | 'email' | 'display_name'>> & {
password?: string;
settings?: Record<string, unknown>;
} = {};
if (includeProfile) {
if (caps.canEditEmail) {
updatePayload.email = editingUser.email;
}
if (caps.canEditDisplayName) {
updatePayload.display_name = editingUser.display_name;
}
if (caps.canEditRole) {
updatePayload.role = editingUser.role;
}
}
if (includePassword && caps.canSetPassword && editPassword) {
updatePayload.password = editPassword;
}
if (settingsPayload && Object.keys(settingsPayload).length > 0) {
updatePayload.settings = settingsPayload;
}
setSaving(true);
try {
await updateAdminUser(editingUser.id, {
...(caps.canEditEmail ? { email: editingUser.email } : {}),
...(caps.canEditDisplayName ? { display_name: editingUser.display_name } : {}),
...(caps.canEditRole ? { role: editingUser.role } : {}),
...(caps.canSetPassword && editPassword ? { password: editPassword } : {}),
...(Object.keys(settingsPayload).length > 0 ? { settings: settingsPayload } : {}),
});
await updateAdminUser(editingUser.id, updatePayload);
onEditSaveSuccess?.();
onShowToast?.('User updated', 'success');
await fetchUsers();
onShowToast?.(
includeSettings && !includeProfile && !includePassword ? 'User preferences updated' : 'User updated',
'success',
);
const refreshedUsers = await fetchUsers();
if (includeProfile && localAdminsBeforeSave > 0 && countLocalPasswordAdmins(refreshedUsers) === 0) {
onShowToast?.(
"No local admin accounts remain. Authentication will fall back to 'No Authentication' until a local admin is created.",
'info',
);
}
return true;
} catch {
return fail('Failed to update user');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update user';
return fail(`Failed to update user: ${message}`);
} finally {
setSaving(false);
}
};
const deleteUser = async (userId: number) => {
const deletedUser = users.find((user) => user.id === userId) || null;
const localAdminsBeforeDelete = countLocalPasswordAdmins(users);
setDeletingUserId(userId);
try {
await deleteAdminUser(userId);
onShowToast?.('User deleted', 'success');
await fetchUsers();
const refreshedUsers = await fetchUsers();
if (deletedUser && deletedUser.auth_source !== 'builtin') {
onShowToast?.(
`${authSourceLabel[deletedUser.auth_source]} users may be re-provisioned by your authentication source on a future login or sync.`,
'info',
);
}
if (localAdminsBeforeDelete > 0 && countLocalPasswordAdmins(refreshedUsers) === 0) {
onShowToast?.(
"No local admin accounts remain. Authentication will fall back to 'No Authentication' until a local admin is created.",
'info',
);
}
return true;
} catch {
return fail('Failed to delete user');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete user';
return fail(`Failed to delete user: ${message}`);
} finally {
setDeletingUserId(null);
}
@@ -27,16 +27,23 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
const shouldSuppressAccessToast = (message: string): boolean =>
message.toLowerCase().includes('admin access required');
const fetchUsers = useCallback(async (): Promise<AdminUser[]> => {
try {
setLoading(true);
setLoadError(null);
const data = await getAdminUsers();
setUsers(data);
return data;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load users';
setLoadError(message);
onShowToast?.(message, 'error');
if (!shouldSuppressAccessToast(message)) {
onShowToast?.(message, 'error');
}
return [];
} finally {
setLoading(false);
}