mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
committed by
GitHub
parent
6552d2a5cb
commit
4dc46e176a
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
ndCreateUser,
|
||||
ndDeleteUser,
|
||||
ndSetUserLibraries,
|
||||
ndUpdateUser,
|
||||
type NdLibrary,
|
||||
type NdUser,
|
||||
} from '../api/navidromeAdmin';
|
||||
import { showToast } from '../utils/toast';
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
encodeServerMagicString,
|
||||
} from '../utils/serverMagicString';
|
||||
import { shortHostFromServerUrl } from '../utils/serverDisplayName';
|
||||
import type { UserFormState } from '../components/settings/UserForm';
|
||||
|
||||
interface UseUserMgmtActionsArgs {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
libraries: NdLibrary[];
|
||||
editing: NdUser | 'new' | null;
|
||||
setEditing: (next: NdUser | 'new' | null) => void;
|
||||
reload: () => Promise<void>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
interface UseUserMgmtActionsResult {
|
||||
busy: boolean;
|
||||
handleSave: (form: UserFormState) => Promise<void>;
|
||||
handleSaveAndGetMagic: (form: UserFormState) => Promise<void>;
|
||||
performDelete: (u: NdUser) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRUD glue for the Navidrome admin user list. Wraps the four Tauri
|
||||
* commands (create / update / set-libraries / delete) with shared
|
||||
* validation, toast surfacing, and a busy flag the parent uses to
|
||||
* disable controls.
|
||||
*
|
||||
* `handleSaveAndGetMagic` is only meaningful in the create flow for
|
||||
* non-admin users — it creates the account, attaches the chosen
|
||||
* libraries, then copies a server-magic-string to the clipboard for
|
||||
* pasting into a fresh client install.
|
||||
*/
|
||||
export function useUserMgmtActions({
|
||||
serverUrl,
|
||||
token,
|
||||
libraries,
|
||||
editing,
|
||||
setEditing,
|
||||
reload,
|
||||
t,
|
||||
}: UseUserMgmtActionsArgs): UseUserMgmtActionsResult {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
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 reload();
|
||||
} 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 reload();
|
||||
} 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;
|
||||
setBusy(true);
|
||||
try {
|
||||
await ndDeleteUser(serverUrl, token, u.id);
|
||||
showToast(t('settings.userMgmtDeleted'), 3000, 'info');
|
||||
await reload();
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
return { busy, handleSave, handleSaveAndGetMagic, performDelete };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
ndListLibraries,
|
||||
ndListUsers,
|
||||
type NdLibrary,
|
||||
type NdUser,
|
||||
} from '../api/navidromeAdmin';
|
||||
|
||||
interface UseUserMgmtDataResult {
|
||||
users: NdUser[];
|
||||
libraries: NdLibrary[];
|
||||
loading: boolean;
|
||||
loadError: string | null;
|
||||
load: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and keep the Navidrome admin users + libraries lists in sync.
|
||||
*
|
||||
* The two requests are **sequential, not parallel**: some nginx setups
|
||||
* with churning upstream keep-alive drop one of 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.
|
||||
*
|
||||
* Tauri's `invoke` rejects with a bare `string` (our Rust commands
|
||||
* return `Err(String)`), so the error normalisation surfaces the real
|
||||
* cause (e.g. `tls handshake eof`) instead of falling back to the
|
||||
* generic i18n string.
|
||||
*/
|
||||
export function useUserMgmtData(serverUrl: string, token: string, t: TFunction): UseUserMgmtDataResult {
|
||||
const [users, setUsers] = useState<NdUser[]>([]);
|
||||
const [libraries, setLibraries] = useState<NdLibrary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
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) {
|
||||
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]);
|
||||
|
||||
return { users, libraries, loading, loadError, load };
|
||||
}
|
||||
Reference in New Issue
Block a user