diff --git a/src/components/settings/AddServerForm.tsx b/src/components/settings/AddServerForm.tsx new file mode 100644 index 00000000..546e67a2 --- /dev/null +++ b/src/components/settings/AddServerForm.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Eye, EyeOff } from 'lucide-react'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { showToast } from '../../utils/toast'; +import { + decodeServerMagicString, + encodeServerMagicString, + DECODED_PASSWORD_VISUAL_MASK, + type ServerMagicPayload, +} from '../../utils/serverMagicString'; +import { shortHostFromServerUrl } from '../../utils/serverDisplayName'; + +export function AddServerForm({ + onSave, + onCancel, + initialInvite = null, +}: { + onSave: (data: Omit) => void; + onCancel: () => void; + initialInvite?: ServerMagicPayload | null; +}) { + const { t } = useTranslation(); + const [form, setForm] = useState({ name: '', url: '', username: '', password: '' }); + const [magicString, setMagicString] = useState(''); + const [showPass, setShowPass] = useState(false); + const [blockPasswordReveal, setBlockPasswordReveal] = useState(false); + + useEffect(() => { + if (!initialInvite) return; + setShowPass(false); + setBlockPasswordReveal(true); + setForm({ + name: (initialInvite.name && initialInvite.name.trim()) || shortHostFromServerUrl(initialInvite.url), + url: initialInvite.url, + username: initialInvite.username, + password: initialInvite.password, + }); + setMagicString(encodeServerMagicString(initialInvite)); + }, [initialInvite]); + + const update = (k: keyof typeof form) => (e: React.ChangeEvent) => + setForm(f => ({ ...f, [k]: e.target.value })); + + const handleMagicStringChange = (e: React.ChangeEvent) => { + const v = e.target.value; + setMagicString(v); + const trimmed = v.trim(); + const decoded = decodeServerMagicString(trimmed); + if (decoded) { + setShowPass(false); + setBlockPasswordReveal(true); + setForm({ + name: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url), + url: decoded.url, + username: decoded.username, + password: decoded.password, + }); + } + }; + + const submit = () => { + const ms = magicString.trim(); + if (ms) { + const decoded = decodeServerMagicString(ms); + if (!decoded) { + showToast(t('login.magicStringInvalid'), 4000, 'error'); + return; + } + onSave({ + name: form.name.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url), + url: decoded.url, + username: decoded.username, + password: decoded.password, + }); + return; + } + if (!form.url.trim()) return; + onSave({ + name: form.name.trim() || form.url.trim(), + url: form.url.trim(), + username: form.username.trim(), + password: form.password, + }); + }; + + return ( +
+

{t('settings.addServerTitle')}

+
+ + +
+
+ + +
+
+
+ + +
+
+ + {blockPasswordReveal ? ( + + ) : ( +
+ + +
+ )} +
+
+
+ + +
+
+ + +
+
+ ); +} diff --git a/src/components/settings/UserForm.tsx b/src/components/settings/UserForm.tsx new file mode 100644 index 00000000..6d0eff26 --- /dev/null +++ b/src/components/settings/UserForm.tsx @@ -0,0 +1,369 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Eye, EyeOff, Shield, Wand2 } from 'lucide-react'; +import { ndUpdateUser, type NdLibrary, type NdUser } from '../../api/navidromeAdmin'; +import { showToast } from '../../utils/toast'; +import { + copyTextToClipboard, + encodeServerMagicString, +} from '../../utils/serverMagicString'; +import { shortHostFromServerUrl } from '../../utils/serverDisplayName'; + +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 [showPass, setShowPass] = useState(false); + 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 str = encodeServerMagicString({ + url: shareServerUrl.trim(), + 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={{ + paddingRight: '2.5rem', + ...(markInvalid && !form.password.trim() ? { borderColor: 'var(--danger)' } : {}), + }} + /> + +
+ {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')} +
+ +
+ )} +
+ + +
+
+ ); +} diff --git a/src/components/settings/UserManagementSection.tsx b/src/components/settings/UserManagementSection.tsx new file mode 100644 index 00000000..d975dbba --- /dev/null +++ b/src/components/settings/UserManagementSection.tsx @@ -0,0 +1,515 @@ +import { useCallback, useEffect, 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 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'); +} + +export function UserManagementSection({ + serverUrl, + token, + currentUsername, +}: { + serverUrl: string; + token: string; + currentUsername: string; +}) { + const { t, i18n } = useTranslation(); + const [users, setUsers] = useState([]); + const [libraries, setLibraries] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [editing, setEditing] = useState(null); + const [confirmingDelete, setConfirmingDelete] = useState(null); + const [busy, setBusy] = useState(false); + const [magicRowUser, setMagicRowUser] = useState(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); + } + }; + + return ( +
+
+ +

{t('settings.userMgmtTitle')}

+
+
+ {t('settings.userMgmtDesc')} +
+ + {loading && ( +
+
+ +
+ )} + + {!loading && loadError && ( +
+
+
{t('settings.userMgmtLoadFriendly')}
+
{loadError}
+
+ +
+ )} + + {!loading && !loadError && ( + <> + {editing ? ( + setEditing(null)} + busy={busy} + /> + ) : ( + + )} + + {users.length === 0 ? ( +
+ {t('settings.userMgmtEmpty')} +
+ ) : ( +
+ {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 ( +
{ 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', + }} + > + + {u.userName} + {u.name && u.name !== u.userName && ( + · {u.name} + )} + {isSelf && ( + + {t('settings.userMgmtYouBadge')} + + )} + {u.isAdmin && ( + + + {t('settings.userMgmtAdminBadge')} + + )} + + {libNames || ''} + + {!u.isAdmin && ( + + )} + + {lastSeen} + + +
+ ); + })} +
+ )} + + )} + { if (confirmingDelete) void performDelete(confirmingDelete); }} + onCancel={() => setConfirmingDelete(null)} + /> + {magicRowUser && createPortal( +
!magicRowSubmitting && setMagicRowUser(null)} + role="dialog" + aria-modal="true" + style={{ alignItems: 'center', paddingTop: 0 }} + > +
e.stopPropagation()} + style={{ maxWidth: '400px' }} + > + +

+ {t('settings.userMgmtMagicStringModalTitle')} +

+

+ {t('settings.userMgmtMagicStringModalDesc', { username: magicRowUser.userName })} +

+

+ {t('settings.userMgmtMagicStringPasswordNavHint')} +

+
+ {t('settings.userMgmtMagicStringPlaintextWarning')} +
+
+ + setMagicRowPassword(e.target.value)} + autoComplete="off" + disabled={magicRowSubmitting} + /> +
+
+ + +
+
+
, + document.body, + )} +
+ ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 72a461af..2776daff 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,14 +1,13 @@ import type { ServerProfile, SeekbarStyle, LoggingMode, LoudnessLufsPreset, TrackPreviewLocation } from '../store/authStoreTypes'; import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, MIX_MIN_RATING_FILTER_MAX_STARS, TRACK_PREVIEW_LOCATIONS } from '../store/authStoreDefaults'; import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; import { version as appVersion } from '../../package.json'; import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock, - Users, UserPlus, Shield, Wand2, Search, Scale, ListMusic + Users, Search, Scale, ListMusic } from 'lucide-react'; import i18n from '../i18n'; import { showToast } from '../utils/toast'; @@ -42,6 +41,7 @@ import { import { useArtistLayoutStore } from '../store/artistLayoutStore'; import { useHomeStore } from '../store/homeStore'; import { useDragDrop } from '../contexts/DragDropContext'; +import { AddServerForm } from '../components/settings/AddServerForm'; import { ArtistLayoutCustomizer } from '../components/settings/ArtistLayoutCustomizer'; import { BackupSection } from '../components/settings/BackupSection'; import { HomeCustomizer } from '../components/settings/HomeCustomizer'; @@ -49,26 +49,16 @@ import { LyricsSourcesCustomizer } from '../components/settings/LyricsSourcesCus import { QueueToolbarCustomizer } from '../components/settings/QueueToolbarCustomizer'; import { ServerGripHandle } from '../components/settings/ServerGripHandle'; import { SidebarCustomizer } from '../components/settings/SidebarCustomizer'; +import { UserManagementSection } from '../components/settings/UserManagementSection'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; -import { - ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser, - ndListLibraries, ndSetUserLibraries, - type NdUser, type NdLibrary, -} from '../api/navidromeAdmin'; +import { ndLogin } from '../api/navidromeAdmin'; import { switchActiveServer } from '../utils/switchActiveServer'; import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'; -import ConfirmModal from '../components/ConfirmModal'; import { Trans, useTranslation } from 'react-i18next'; import Equalizer from '../components/Equalizer'; import StarRating from '../components/StarRating'; import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity'; -import { - decodeServerMagicString, - encodeServerMagicString, - copyTextToClipboard, - DECODED_PASSWORD_VISUAL_MASK, - type ServerMagicPayload, -} from '../utils/serverMagicString'; +import { type ServerMagicPayload } from '../utils/serverMagicString'; import { shortHostFromServerUrl, serverListDisplayLabel } from '../utils/serverDisplayName'; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; @@ -464,1012 +454,6 @@ function matchScore(haystack: string, needle: string): number { return Math.max(1, 100 - Math.min(99, hi - n.length)); } -function AddServerForm({ - onSave, - onCancel, - initialInvite = null, -}: { - onSave: (data: Omit) => void; - onCancel: () => void; - initialInvite?: ServerMagicPayload | null; -}) { - const { t } = useTranslation(); - const [form, setForm] = useState({ name: '', url: '', username: '', password: '' }); - const [magicString, setMagicString] = useState(''); - const [showPass, setShowPass] = useState(false); - const [blockPasswordReveal, setBlockPasswordReveal] = useState(false); - - useEffect(() => { - if (!initialInvite) return; - setShowPass(false); - setBlockPasswordReveal(true); - setForm({ - name: (initialInvite.name && initialInvite.name.trim()) || shortHostFromServerUrl(initialInvite.url), - url: initialInvite.url, - username: initialInvite.username, - password: initialInvite.password, - }); - setMagicString(encodeServerMagicString(initialInvite)); - }, [initialInvite]); - - const update = (k: keyof typeof form) => (e: React.ChangeEvent) => - setForm(f => ({ ...f, [k]: e.target.value })); - - const handleMagicStringChange = (e: React.ChangeEvent) => { - const v = e.target.value; - setMagicString(v); - const trimmed = v.trim(); - const decoded = decodeServerMagicString(trimmed); - if (decoded) { - setShowPass(false); - setBlockPasswordReveal(true); - setForm({ - name: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url), - url: decoded.url, - username: decoded.username, - password: decoded.password, - }); - } - }; - - const submit = () => { - const ms = magicString.trim(); - if (ms) { - const decoded = decodeServerMagicString(ms); - if (!decoded) { - showToast(t('login.magicStringInvalid'), 4000, 'error'); - return; - } - onSave({ - name: form.name.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url), - url: decoded.url, - username: decoded.username, - password: decoded.password, - }); - return; - } - if (!form.url.trim()) return; - onSave({ - name: form.name.trim() || form.url.trim(), - url: form.url.trim(), - username: form.username.trim(), - password: form.password, - }); - }; - - return ( -
-

{t('settings.addServerTitle')}

-
- - -
-
- - -
-
-
- - -
-
- - {blockPasswordReveal ? ( - - ) : ( -
- - -
- )} -
-
-
- - -
-
- - -
-
- ); -} - -interface UserFormState { - userName: string; - name: string; - email: string; - password: string; - isAdmin: boolean; - libraryIds: number[]; -} - -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, - }; -} - -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 [showPass, setShowPass] = useState(false); - 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 str = encodeServerMagicString({ - url: shareServerUrl.trim(), - 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={{ - paddingRight: '2.5rem', - ...(markInvalid && !form.password.trim() ? { borderColor: 'var(--danger)' } : {}), - }} - /> - -
- {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')} -
- -
- )} -
- - -
-
- ); -} - -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'); -} - -function UserManagementSection({ - serverUrl, - token, - currentUsername, -}: { - serverUrl: string; - token: string; - currentUsername: string; -}) { - const { t, i18n } = useTranslation(); - const [users, setUsers] = useState([]); - const [libraries, setLibraries] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [editing, setEditing] = useState(null); - const [confirmingDelete, setConfirmingDelete] = useState(null); - const [busy, setBusy] = useState(false); - const [magicRowUser, setMagicRowUser] = useState(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); - } - }; - - return ( -
-
- -

{t('settings.userMgmtTitle')}

-
-
- {t('settings.userMgmtDesc')} -
- - {loading && ( -
-
- -
- )} - - {!loading && loadError && ( -
-
-
{t('settings.userMgmtLoadFriendly')}
-
{loadError}
-
- -
- )} - - {!loading && !loadError && ( - <> - {editing ? ( - setEditing(null)} - busy={busy} - /> - ) : ( - - )} - - {users.length === 0 ? ( -
- {t('settings.userMgmtEmpty')} -
- ) : ( -
- {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 ( -
{ 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', - }} - > - - {u.userName} - {u.name && u.name !== u.userName && ( - · {u.name} - )} - {isSelf && ( - - {t('settings.userMgmtYouBadge')} - - )} - {u.isAdmin && ( - - - {t('settings.userMgmtAdminBadge')} - - )} - - {libNames || ''} - - {!u.isAdmin && ( - - )} - - {lastSeen} - - -
- ); - })} -
- )} - - )} - { if (confirmingDelete) void performDelete(confirmingDelete); }} - onCancel={() => setConfirmingDelete(null)} - /> - {magicRowUser && createPortal( -
!magicRowSubmitting && setMagicRowUser(null)} - role="dialog" - aria-modal="true" - style={{ alignItems: 'center', paddingTop: 0 }} - > -
e.stopPropagation()} - style={{ maxWidth: '400px' }} - > - -

- {t('settings.userMgmtMagicStringModalTitle')} -

-

- {t('settings.userMgmtMagicStringModalDesc', { username: magicRowUser.userName })} -

-

- {t('settings.userMgmtMagicStringPasswordNavHint')} -

-
- {t('settings.userMgmtMagicStringPlaintextWarning')} -
-
- - setMagicRowPassword(e.target.value)} - autoComplete="off" - disabled={magicRowSubmitting} - /> -
-
- - -
-
-
, - document.body, - )} -
- ); -} function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;