Files
Psychotoxical-psysonic/src/components/settings/UserManagementSection.tsx
T
Frank Stellmacher 4dc46e176a 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.
2026-05-14 00:45:48 +02:00

154 lines
5.2 KiB
TypeScript

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RotateCcw, UserPlus, Users } from 'lucide-react';
import type { NdUser } from '../../api/navidromeAdmin';
import ConfirmModal from '../ConfirmModal';
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,
token,
currentUsername,
}: {
serverUrl: string;
token: string;
currentUsername: string;
}) {
const { t, i18n } = useTranslation();
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 [magicRowUser, setMagicRowUser] = useState<NdUser | null>(null);
const { busy, handleSave, handleSaveAndGetMagic, performDelete } = useUserMgmtActions({
serverUrl, token, libraries, editing, setEditing, reload: load, t,
});
return (
<section className="settings-section">
<div className="settings-section-header">
<Users size={18} />
<h2>{t('settings.userMgmtTitle')}</h2>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.userMgmtDesc')}
</div>
{loading && (
<div className="settings-card" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="spinner" style={{ width: 14, height: 14 }} />
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}></span>
</div>
)}
{!loading && loadError && (
<div
className="settings-card"
style={{
color: 'var(--danger)',
fontSize: 13,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t('settings.userMgmtLoadFriendly')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', wordBreak: 'break-word' }}>{loadError}</div>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => void load()}
style={{ flexShrink: 0 }}
>
<RotateCcw size={14} /> {t('settings.userMgmtRetry')}
</button>
</div>
)}
{!loading && !loadError && (
<>
{editing ? (
<UserForm
initial={editing === 'new' ? null : editing}
libraries={libraries}
shareServerUrl={serverUrl}
ndToken={token}
onUsersDirty={load}
onSave={handleSave}
onSaveAndGetMagic={editing === 'new' ? handleSaveAndGetMagic : undefined}
onCancel={() => setEditing(null)}
busy={busy}
/>
) : (
<button
className="btn btn-surface"
style={{ marginBottom: '0.75rem' }}
onClick={() => setEditing('new')}
disabled={busy}
>
<UserPlus size={16} /> {t('settings.userMgmtAddUser')}
</button>
)}
{users.length === 0 ? (
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
{t('settings.userMgmtEmpty')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{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>
)}
</>
)}
<ConfirmModal
open={!!confirmingDelete}
title={t('settings.userMgmtDelete')}
message={confirmingDelete
? t('settings.userMgmtConfirmDelete', { username: confirmingDelete.userName })
: ''}
confirmLabel={t('settings.userMgmtDelete')}
cancelLabel={t('settings.userMgmtCancel')}
danger
onConfirm={() => {
if (!confirmingDelete) return;
const target = confirmingDelete;
setConfirmingDelete(null);
void performDelete(target);
}}
onCancel={() => setConfirmingDelete(null)}
/>
{magicRowUser && (
<MagicStringModal
user={magicRowUser}
serverUrl={serverUrl}
token={token}
onClose={() => setMagicRowUser(null)}
onSuccess={load}
t={t}
/>
)}
</section>
);
}