mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 20:51:10 +01:00
Patch: Further multi-user fixes (#613)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user