refactor(user-mgmt): I.5 — split UserManagementSection.tsx 515 → 153 LOC across 6 files (#677)

* refactor(user-mgmt): extract formatLastSeen helper

Move the relative-time formatter (with the Navidrome
'0001-01-01T00:00:00Z' epoch guard) into utils/userMgmtHelpers.ts.

UserManagementSection.tsx: 515 → 499 LOC.

* refactor(user-mgmt): extract useUserMgmtData hook

Pull users + libraries state, sequential admin-API fetch, and the
nginx-friendly error normalisation into hooks/useUserMgmtData.ts.

UserManagementSection.tsx: 499 → 464 LOC.

* refactor(user-mgmt): extract useUserMgmtActions hook

Bundle handleSave (covers create + edit + library assignment),
handleSaveAndGetMagic (new non-admin user → encoded magic string on
clipboard), and performDelete into hooks/useUserMgmtActions.ts. The
delete confirmation modal now closes inline in the parent before
delegating to performDelete so the hook stays agnostic of UI state.

UserManagementSection.tsx: 464 → 343 LOC.

* refactor(user-mgmt): extract UserMgmtRow subcomponent

Move the per-user list row (user/admin badges, lib-names blob, magic-
string + delete actions, keyboard activation) into
components/settings/userMgmt/UserMgmtRow.tsx.

UserManagementSection.tsx: 343 → 272 LOC.

* refactor(user-mgmt): extract MagicStringModal subcomponent

Move the per-user magic-string portal modal (password re-set + clipboard
copy of the encoded server-magic-string) into
components/settings/userMgmt/MagicStringModal.tsx. Internal password and
submitting state move into the modal; the parent only owns which user is
targeted.

UserManagementSection.tsx: 272 → 153 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-14 00:45:48 +02:00
committed by GitHub
parent 6552d2a5cb
commit 4dc46e176a
6 changed files with 596 additions and 403 deletions
+41 -403
View File
@@ -1,42 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { RotateCcw, Shield, Trash2, User, UserPlus, Users, Wand2, X } from 'lucide-react';
import {
ndCreateUser,
ndDeleteUser,
ndListLibraries,
ndListUsers,
ndSetUserLibraries,
ndUpdateUser,
type NdLibrary,
type NdUser,
} from '../../api/navidromeAdmin';
import { RotateCcw, UserPlus, Users } from 'lucide-react';
import type { NdUser } from '../../api/navidromeAdmin';
import ConfirmModal from '../ConfirmModal';
import { showToast } from '../../utils/toast';
import {
copyTextToClipboard,
encodeServerMagicString,
} from '../../utils/serverMagicString';
import { shortHostFromServerUrl } from '../../utils/serverDisplayName';
import { UserForm, type UserFormState } from './UserForm';
function formatLastSeen(iso: string | null | undefined, locale: string, neverLabel: string): string {
if (!iso) return neverLabel;
const t = new Date(iso).getTime();
// Navidrome returns "0001-01-01T00:00:00Z" for never-accessed users → guard against bogus epochs.
if (!Number.isFinite(t) || t < 1_000_000_000_000) return neverLabel;
const diffSec = (t - Date.now()) / 1000;
const abs = Math.abs(diffSec);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
if (abs < 60) return rtf.format(Math.round(diffSec), 'second');
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
if (abs < 604800) return rtf.format(Math.round(diffSec / 86400), 'day');
if (abs < 2592000) return rtf.format(Math.round(diffSec / 604800), 'week');
if (abs < 31536000) return rtf.format(Math.round(diffSec / 2592000), 'month');
return rtf.format(Math.round(diffSec / 31536000), 'year');
}
import { useUserMgmtData } from '../../hooks/useUserMgmtData';
import { useUserMgmtActions } from '../../hooks/useUserMgmtActions';
import { UserForm } from './UserForm';
import { UserMgmtRow } from './userMgmt/UserMgmtRow';
import { MagicStringModal } from './userMgmt/MagicStringModal';
export function UserManagementSection({
serverUrl,
@@ -48,172 +19,13 @@ export function UserManagementSection({
currentUsername: string;
}) {
const { t, i18n } = useTranslation();
const [users, setUsers] = useState<NdUser[]>([]);
const [libraries, setLibraries] = useState<NdLibrary[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const { users, libraries, loading, loadError, load } = useUserMgmtData(serverUrl, token, t);
const [editing, setEditing] = useState<NdUser | 'new' | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState<NdUser | null>(null);
const [busy, setBusy] = useState(false);
const [magicRowUser, setMagicRowUser] = useState<NdUser | null>(null);
const [magicRowPassword, setMagicRowPassword] = useState('');
const [magicRowSubmitting, setMagicRowSubmitting] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
// Sequential, not parallel: nginx setups with churning upstream
// keep-alive drop one of the two parallel TLS connections. Doing
// users first then libraries keeps us on one connection at a time
// and pairs cleanly with the nd_retry backoff on the Rust side.
const list = await ndListUsers(serverUrl, token);
const libs = await ndListLibraries(serverUrl, token).catch(() => [] as NdLibrary[]);
setUsers([...list].sort((a, b) => a.userName.localeCompare(b.userName)));
setLibraries([...libs].sort((a, b) => a.name.localeCompare(b.name)));
} catch (e) {
// Tauri invoke rejects with a plain string (our Rust returns Err(String)),
// not an Error instance. Normalise so the surfaced message is the real
// cause (e.g. "tls handshake eof") rather than the generic i18n fallback.
const raw = typeof e === 'string'
? e
: (e instanceof Error && e.message)
? e.message
: '';
const prefix = t('settings.userMgmtLoadError');
setLoadError(raw ? `${prefix} ${raw}` : prefix);
} finally {
setLoading(false);
}
}, [serverUrl, token, t]);
useEffect(() => { void load(); }, [load]);
const handleSave = async (form: UserFormState) => {
const userName = form.userName.trim();
const name = form.name.trim();
const email = form.email.trim();
if (editing === 'new') {
if (!userName || !name || !form.password.trim()) {
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
return;
}
} else if (editing) {
if (!userName || !name) {
showToast(t('settings.userMgmtValidationMissingIdentity'), 4000, 'error');
return;
}
}
if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) {
showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error');
return;
}
if (!token) return;
setBusy(true);
try {
let targetId: string;
if (editing === 'new') {
const created = await ndCreateUser(serverUrl, token, {
userName, name, email, password: form.password, isAdmin: form.isAdmin,
});
targetId = created.id;
showToast(t('settings.userMgmtCreated'), 3000, 'info');
} else if (editing) {
await ndUpdateUser(serverUrl, token, editing.id, {
userName, name, email, password: form.password, isAdmin: form.isAdmin,
});
targetId = editing.id;
showToast(t('settings.userMgmtUpdated'), 3000, 'info');
} else {
return;
}
if (!form.isAdmin && form.libraryIds.length > 0) {
try {
await ndSetUserLibraries(serverUrl, token, targetId, form.libraryIds);
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : String(e);
showToast(`${t('settings.userMgmtLibrariesUpdateError')}: ${msg}`, 5000, 'error');
}
}
setEditing(null);
await load();
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
const fallback = editing === 'new'
? t('settings.userMgmtCreateError')
: t('settings.userMgmtUpdateError');
showToast(msg ?? fallback, 5000, 'error');
} finally {
setBusy(false);
}
};
const handleSaveAndGetMagic = async (form: UserFormState) => {
if (editing !== 'new' || form.isAdmin) return;
const userName = form.userName.trim();
const name = form.name.trim();
const email = form.email.trim();
if (!userName || !name || !form.password.trim()) {
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
return;
}
if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) {
showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error');
return;
}
if (!token) return;
setBusy(true);
try {
const created = await ndCreateUser(serverUrl, token, {
userName, name, email, password: form.password, isAdmin: form.isAdmin,
});
const targetId = created.id;
showToast(t('settings.userMgmtCreated'), 3000, 'info');
if (!form.isAdmin && form.libraryIds.length > 0) {
try {
await ndSetUserLibraries(serverUrl, token, targetId, form.libraryIds);
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : String(e);
showToast(`${t('settings.userMgmtLibrariesUpdateError')}: ${msg}`, 5000, 'error');
}
}
const str = encodeServerMagicString({
url: serverUrl.trim(),
username: userName,
password: form.password,
name: shortHostFromServerUrl(serverUrl),
});
const ok = await copyTextToClipboard(str);
showToast(
ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'),
ok ? 3000 : 5000,
ok ? 'info' : 'error',
);
setEditing(null);
await load();
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
showToast(msg ?? t('settings.userMgmtCreateError'), 5000, 'error');
} finally {
setBusy(false);
}
};
const performDelete = async (u: NdUser) => {
if (!token) return;
setConfirmingDelete(null);
setBusy(true);
try {
await ndDeleteUser(serverUrl, token, u.id);
showToast(t('settings.userMgmtDeleted'), 3000, 'info');
await load();
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : t('settings.userMgmtDeleteError'));
showToast(msg, 5000, 'error');
} finally {
setBusy(false);
}
};
const { busy, handleSave, handleSaveAndGetMagic, performDelete } = useUserMgmtActions({
serverUrl, token, libraries, editing, setEditing, reload: load, t,
});
return (
<section className="settings-section">
@@ -291,94 +103,20 @@ export function UserManagementSection({
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{users.map(u => {
const isSelf = u.userName === currentUsername;
const libNames = u.isAdmin
? null
: u.libraryIds.length === 0
? t('settings.userMgmtNoLibraries')
: libraries.filter(l => u.libraryIds.includes(l.id)).map(l => l.name).join(', ');
const lastSeen = formatLastSeen(u.lastAccessAt, i18n.language, t('settings.userMgmtNeverSeen'));
const lastSeenAbsolute = u.lastAccessAt
? new Date(u.lastAccessAt).toLocaleString(i18n.language)
: '';
return (
<div
key={u.id}
className="settings-card user-row"
role="button"
tabIndex={0}
onClick={() => { if (!busy) setEditing(u); }}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !busy) {
e.preventDefault();
setEditing(u);
}
}}
style={{
padding: '6px 10px',
display: 'flex',
alignItems: 'center',
gap: 10,
cursor: busy ? 'default' : 'pointer',
}}
>
<User size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontWeight: 600, fontSize: 13, flexShrink: 0 }}>{u.userName}</span>
{u.name && u.name !== u.userName && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', flexShrink: 0 }}>· {u.name}</span>
)}
{isSelf && (
<span style={{ fontSize: 10, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: 10, fontWeight: 600, flexShrink: 0 }}>
{t('settings.userMgmtYouBadge')}
</span>
)}
{u.isAdmin && (
<span
style={{ fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 10, fontWeight: 600, background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)', color: 'var(--text-primary)', flexShrink: 0 }}
data-tooltip={t('settings.userMgmtRoleAdmin')}
>
<Shield size={10} />
{t('settings.userMgmtAdminBadge')}
</span>
)}
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
{libNames || ''}
</span>
{!u.isAdmin && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: '2px 6px', flexShrink: 0 }}
onClick={(e) => {
e.stopPropagation();
setMagicRowUser(u);
setMagicRowPassword('');
}}
disabled={busy}
data-tooltip={t('settings.userMgmtMagicStringGenerate')}
>
<Wand2 size={14} />
</button>
)}
<span
style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0 }}
data-tooltip={lastSeenAbsolute || undefined}
>
{lastSeen}
</span>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)', padding: '2px 6px', flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); setConfirmingDelete(u); }}
disabled={busy || isSelf}
data-tooltip={t('settings.userMgmtDelete')}
>
<Trash2 size={14} />
</button>
</div>
);
})}
{users.map(u => (
<UserMgmtRow
key={u.id}
user={u}
libraries={libraries}
isSelf={u.userName === currentUsername}
busy={busy}
onEdit={setEditing}
onRequestDelete={setConfirmingDelete}
onRequestMagic={setMagicRowUser}
t={t}
i18n={i18n}
/>
))}
</div>
)}
</>
@@ -392,123 +130,23 @@ export function UserManagementSection({
confirmLabel={t('settings.userMgmtDelete')}
cancelLabel={t('settings.userMgmtCancel')}
danger
onConfirm={() => { if (confirmingDelete) void performDelete(confirmingDelete); }}
onConfirm={() => {
if (!confirmingDelete) return;
const target = confirmingDelete;
setConfirmingDelete(null);
void performDelete(target);
}}
onCancel={() => setConfirmingDelete(null)}
/>
{magicRowUser && createPortal(
<div
className="modal-overlay"
onClick={() => !magicRowSubmitting && setMagicRowUser(null)}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '400px' }}
>
<button
type="button"
className="modal-close"
onClick={() => !magicRowSubmitting && setMagicRowUser(null)}
aria-label={t('settings.userMgmtCancel')}
>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>
{t('settings.userMgmtMagicStringModalTitle')}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5, fontSize: 13 }}>
{t('settings.userMgmtMagicStringModalDesc', { username: magicRowUser.userName })}
</p>
<p style={{ color: 'var(--text-muted)', marginBottom: '0.75rem', lineHeight: 1.45, fontSize: 12 }}>
{t('settings.userMgmtMagicStringPasswordNavHint')}
</p>
<div
role="note"
style={{
fontSize: 11,
lineHeight: 1.45,
marginBottom: '1rem',
padding: '8px 10px',
borderRadius: 6,
border: '1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent)',
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.userMgmtMagicStringPlaintextWarning')}
</div>
<div className="form-group" style={{ marginBottom: '1.25rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.userMgmtPassword')}</label>
<input
className="input"
type="password"
value={magicRowPassword}
onChange={e => setMagicRowPassword(e.target.value)}
autoComplete="off"
disabled={magicRowSubmitting}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
type="button"
className="btn btn-ghost"
onClick={() => !magicRowSubmitting && setMagicRowUser(null)}
disabled={magicRowSubmitting}
>
{t('settings.userMgmtCancel')}
</button>
<button
type="button"
className="btn btn-primary"
disabled={!magicRowPassword.trim() || magicRowSubmitting}
onClick={() => {
if (!magicRowUser || !magicRowPassword.trim() || !token) return;
void (async () => {
setMagicRowSubmitting(true);
try {
await ndUpdateUser(serverUrl, token, magicRowUser.id, {
userName: magicRowUser.userName,
name: magicRowUser.name,
email: magicRowUser.email,
password: magicRowPassword.trim(),
isAdmin: magicRowUser.isAdmin,
});
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
showToast(msg ?? t('settings.userMgmtUpdateError'), 5000, 'error');
return;
} finally {
setMagicRowSubmitting(false);
}
const str = encodeServerMagicString({
url: serverUrl,
username: magicRowUser.userName,
password: magicRowPassword.trim(),
name: shortHostFromServerUrl(serverUrl),
});
const ok = await copyTextToClipboard(str);
showToast(
ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'),
ok ? 3000 : 5000,
ok ? 'info' : 'error',
);
if (ok) {
setMagicRowUser(null);
setMagicRowPassword('');
await load();
}
})();
}}
>
{t('settings.userMgmtMagicStringModalConfirm')}
</button>
</div>
</div>
</div>,
document.body,
{magicRowUser && (
<MagicStringModal
user={magicRowUser}
serverUrl={serverUrl}
token={token}
onClose={() => setMagicRowUser(null)}
onSuccess={load}
t={t}
/>
)}
</section>
);