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
@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import type { TFunction } from 'i18next';
import { ndUpdateUser, type NdUser } from '../../../api/navidromeAdmin';
import { showToast } from '../../../utils/toast';
import {
copyTextToClipboard,
encodeServerMagicString,
} from '../../../utils/serverMagicString';
import { shortHostFromServerUrl } from '../../../utils/serverDisplayName';
interface Props {
user: NdUser;
serverUrl: string;
token: string;
onClose: () => void;
onSuccess: () => Promise<void> | void;
t: TFunction;
}
/**
* Generate-magic-string flow for an existing non-admin user. The admin
* supplies a new password (Navidrome doesn't expose passwords in admin
* APIs, so we must re-set one); on success we encode it into a server
* magic string, copy it to the clipboard, and let the parent reload the
* list.
*
* Rendered into `document.body` via portal so the modal escapes the
* settings overflow box.
*/
export function MagicStringModal({
user,
serverUrl,
token,
onClose,
onSuccess,
t,
}: Props) {
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const closeIfIdle = () => {
if (!submitting) onClose();
};
const handleConfirm = () => {
if (!password.trim() || !token) return;
void (async () => {
setSubmitting(true);
try {
await ndUpdateUser(serverUrl, token, user.id, {
userName: user.userName,
name: user.name,
email: user.email,
password: password.trim(),
isAdmin: user.isAdmin,
});
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
showToast(msg ?? t('settings.userMgmtUpdateError'), 5000, 'error');
setSubmitting(false);
return;
}
setSubmitting(false);
const str = encodeServerMagicString({
url: serverUrl,
username: user.userName,
password: password.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) {
onClose();
await onSuccess();
}
})();
};
return createPortal(
<div
className="modal-overlay"
onClick={closeIfIdle}
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={closeIfIdle}
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: user.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={password}
onChange={e => setPassword(e.target.value)}
autoComplete="off"
disabled={submitting}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
type="button"
className="btn btn-ghost"
onClick={closeIfIdle}
disabled={submitting}
>
{t('settings.userMgmtCancel')}
</button>
<button
type="button"
className="btn btn-primary"
disabled={!password.trim() || submitting}
onClick={handleConfirm}
>
{t('settings.userMgmtMagicStringModalConfirm')}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,123 @@
import React from 'react';
import { Shield, Trash2, User, Wand2 } from 'lucide-react';
import type { i18n as I18nType, TFunction } from 'i18next';
import type { NdLibrary, NdUser } from '../../../api/navidromeAdmin';
import { formatLastSeen } from '../../../utils/userMgmtHelpers';
interface Props {
user: NdUser;
libraries: NdLibrary[];
isSelf: boolean;
busy: boolean;
onEdit: (u: NdUser) => void;
onRequestDelete: (u: NdUser) => void;
onRequestMagic: (u: NdUser) => void;
t: TFunction;
i18n: I18nType;
}
/**
* Single user list row in the admin user management section.
*
* Whole row is keyboard-activatable as a button (Enter / Space → edit
* mode) since the click target is a `<div>` rather than a real button —
* we need nested action buttons (magic string, delete) and a `<button>`
* inside a `<button>` is invalid HTML.
*
* The library-names column is built from the union of the user's
* libraryIds and the live libraries list, so it stays in sync if the
* admin re-assigns libraries elsewhere.
*/
export function UserMgmtRow({
user: u,
libraries,
isSelf,
busy,
onEdit,
onRequestDelete,
onRequestMagic,
t,
i18n,
}: Props) {
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
className="settings-card user-row"
role="button"
tabIndex={0}
onClick={() => { if (!busy) onEdit(u); }}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !busy) {
e.preventDefault();
onEdit(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(); onRequestMagic(u); }}
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(); onRequestDelete(u); }}
disabled={busy || isSelf}
data-tooltip={t('settings.userMgmtDelete')}
>
<Trash2 size={14} />
</button>
</div>
);
}