import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Shield, Wand2 } from 'lucide-react'; import { ndUpdateUser, type NdLibrary, type NdUser } from '../../api/navidromeAdmin'; import { showToast } from '../../utils/ui/toast'; import { copyTextToClipboard, encodeServerMagicString, magicPayloadAddressFields, } from '../../utils/server/serverMagicString'; import { shortHostFromServerUrl } from '../../utils/server/serverDisplayName'; import { useAuthStore } from '../../store/authStore'; export interface UserFormState { userName: string; name: string; email: string; password: string; isAdmin: boolean; libraryIds: number[]; } export function initialUserFormState(u: NdUser | undefined, allLibraries: NdLibrary[]): UserFormState { const defaultIds = allLibraries.map(l => l.id); return { userName: u?.userName ?? '', name: u?.name ?? '', email: u?.email ?? '', password: '', isAdmin: !!u?.isAdmin, libraryIds: u ? [...u.libraryIds] : defaultIds, }; } export function UserForm({ initial, libraries, shareServerUrl, ndToken, onUsersDirty, onSave, onSaveAndGetMagic, onCancel, busy, }: { initial: NdUser | null; libraries: NdLibrary[]; shareServerUrl: string; ndToken: string; onUsersDirty?: () => void | Promise; onSave: (form: UserFormState) => void; /** New user only: create on Navidrome then copy magic string to clipboard. */ onSaveAndGetMagic?: (form: UserFormState) => void | Promise; onCancel: () => void; busy: boolean; }) { const { t } = useTranslation(); const [form, setForm] = useState(() => initialUserFormState(initial ?? undefined, libraries)); const [magicGenBusy, setMagicGenBusy] = useState(false); const [showNewUserRequiredErrors, setShowNewUserRequiredErrors] = useState(false); const isEdit = !!initial; useEffect(() => { setShowNewUserRequiredErrors(false); }, [initial?.id]); useEffect(() => { if (!isEdit && form.userName.trim() && form.name.trim() && form.password.trim()) { setShowNewUserRequiredErrors(false); } }, [isEdit, form.userName, form.name, form.password]); const set = (k: K, v: UserFormState[K]) => setForm(f => ({ ...f, [k]: v })); const toggleLib = (id: number) => setForm(f => ({ ...f, libraryIds: f.libraryIds.includes(id) ? f.libraryIds.filter(x => x !== id) : [...f.libraryIds, id], })); const newUserPasswordOk = form.password.trim().length > 0; const canSave = form.userName.trim().length > 0 && form.name.trim().length > 0 && (isEdit || newUserPasswordOk) && (form.isAdmin || form.libraryIds.length > 0); const generateMagicString = async () => { if (!shareServerUrl.trim() || !form.password.trim() || !initial || !ndToken.trim()) return; setMagicGenBusy(true); try { await ndUpdateUser(shareServerUrl.trim(), ndToken, initial.id, { userName: form.userName.trim(), name: form.name.trim(), email: form.email.trim(), password: form.password, isAdmin: form.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 { setMagicGenBusy(false); } const addressFields = magicPayloadAddressFields( shareServerUrl.trim(), useAuthStore.getState().servers, ); const str = encodeServerMagicString({ ...addressFields, username: form.userName.trim(), password: form.password, name: shortHostFromServerUrl(shareServerUrl), }); const ok = await copyTextToClipboard(str); showToast( ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'), ok ? 3000 : 5000, ok ? 'info' : 'error', ); if (ok) void onUsersDirty?.(); }; const runSaveAndGetMagic = async () => { if (!onSaveAndGetMagic) return; if (!form.userName.trim() || !form.name.trim() || !form.password.trim()) { setShowNewUserRequiredErrors(true); showToast(t('settings.userMgmtValidationMissing'), 4000, 'error'); return; } if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) { showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error'); return; } setMagicGenBusy(true); try { await onSaveAndGetMagic(form); } finally { setMagicGenBusy(false); } }; const invalidNewUserCore = !isEdit && (!form.userName.trim() || !form.name.trim() || !form.password.trim()); const trySave = () => { if (invalidNewUserCore) { setShowNewUserRequiredErrors(true); showToast(t('settings.userMgmtValidationMissing'), 4000, 'error'); return; } onSave(form); }; const markInvalid = showNewUserRequiredErrors && !isEdit; return (

{isEdit ? t('settings.userMgmtEditUserTitle') : t('settings.userMgmtAddUserTitle')}

set('userName', e.target.value)} disabled={isEdit} autoComplete="off" aria-invalid={markInvalid && !form.userName.trim()} style={markInvalid && !form.userName.trim() ? { borderColor: 'var(--danger)' } : undefined} />
set('name', e.target.value)} autoComplete="off" aria-invalid={markInvalid && !form.name.trim()} style={markInvalid && !form.name.trim() ? { borderColor: 'var(--danger)' } : undefined} />
set('email', e.target.value)} autoComplete="off" />
set('password', e.target.value)} placeholder="••••••••" autoComplete="new-password" aria-invalid={markInvalid && !form.password.trim()} style={markInvalid && !form.password.trim() ? { borderColor: 'var(--danger)' } : undefined} /> {isEdit && (
{t('settings.userMgmtPasswordEditHint')}
)}
{form.isAdmin ? (
{t('settings.userMgmtLibrariesAdminHint')}
) : libraries.length === 0 ? (
{t('settings.userMgmtLibrariesEmpty')}
) : ( <>
{libraries.map(lib => ( ))}
{form.libraryIds.length === 0 && (
{t('settings.userMgmtLibrariesValidation')}
)} )}
{!form.isAdmin && !isEdit && onSaveAndGetMagic && shareServerUrl.trim() && ndToken.trim() && (
{t('settings.userMgmtMagicStringPlaintextWarning')}
)} {!form.isAdmin && isEdit && shareServerUrl.trim() && form.password.trim().length > 0 && ndToken.trim() && (
{t('settings.userMgmtMagicStringPasswordNavHint')}
{t('settings.userMgmtMagicStringPlaintextWarning')}
)}
); }