diff --git a/src/components/settings/UserManagementSection.tsx b/src/components/settings/UserManagementSection.tsx index d975dbba..5e4e9180 100644 --- a/src/components/settings/UserManagementSection.tsx +++ b/src/components/settings/UserManagementSection.tsx @@ -1,42 +1,13 @@ -import { useCallback, useEffect, useState } from 'react'; +import { 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 { RotateCcw, UserPlus, Users } from 'lucide-react'; +import 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'); -} +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, @@ -48,172 +19,13 @@ export function UserManagementSection({ 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 { users, libraries, loading, loadError, load } = useUserMgmtData(serverUrl, token, t); 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); - } - }; + const { busy, handleSave, handleSaveAndGetMagic, performDelete } = useUserMgmtActions({ + serverUrl, token, libraries, editing, setEditing, reload: load, t, + }); return (
@@ -291,94 +103,20 @@ export function UserManagementSection({ ) : (
- {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} - - -
- ); - })} + {users.map(u => ( + + ))}
)} @@ -392,123 +130,23 @@ export function UserManagementSection({ confirmLabel={t('settings.userMgmtDelete')} cancelLabel={t('settings.userMgmtCancel')} danger - onConfirm={() => { if (confirmingDelete) void performDelete(confirmingDelete); }} + onConfirm={() => { + if (!confirmingDelete) return; + const target = confirmingDelete; + setConfirmingDelete(null); + void performDelete(target); + }} 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, + {magicRowUser && ( + setMagicRowUser(null)} + onSuccess={load} + t={t} + /> )}
); diff --git a/src/components/settings/userMgmt/MagicStringModal.tsx b/src/components/settings/userMgmt/MagicStringModal.tsx new file mode 100644 index 00000000..4f0f106f --- /dev/null +++ b/src/components/settings/userMgmt/MagicStringModal.tsx @@ -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; + 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( +
+
e.stopPropagation()} + style={{ maxWidth: '400px' }} + > + +

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

+

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

+

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

+
+ {t('settings.userMgmtMagicStringPlaintextWarning')} +
+
+ + setPassword(e.target.value)} + autoComplete="off" + disabled={submitting} + /> +
+
+ + +
+
+
, + document.body, + ); +} diff --git a/src/components/settings/userMgmt/UserMgmtRow.tsx b/src/components/settings/userMgmt/UserMgmtRow.tsx new file mode 100644 index 00000000..03f980dd --- /dev/null +++ b/src/components/settings/userMgmt/UserMgmtRow.tsx @@ -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 `
` rather than a real button — + * we need nested action buttons (magic string, delete) and a ` + )} + + {lastSeen} + + +
+ ); +} diff --git a/src/hooks/useUserMgmtActions.ts b/src/hooks/useUserMgmtActions.ts new file mode 100644 index 00000000..02e5bd85 --- /dev/null +++ b/src/hooks/useUserMgmtActions.ts @@ -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; + t: TFunction; +} + +interface UseUserMgmtActionsResult { + busy: boolean; + handleSave: (form: UserFormState) => Promise; + handleSaveAndGetMagic: (form: UserFormState) => Promise; + performDelete: (u: NdUser) => Promise; +} + +/** + * 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 }; +} diff --git a/src/hooks/useUserMgmtData.ts b/src/hooks/useUserMgmtData.ts new file mode 100644 index 00000000..92ee2252 --- /dev/null +++ b/src/hooks/useUserMgmtData.ts @@ -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; +} + +/** + * 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([]); + const [libraries, setLibraries] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(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 }; +} diff --git a/src/utils/userMgmtHelpers.ts b/src/utils/userMgmtHelpers.ts new file mode 100644 index 00000000..c6886dbd --- /dev/null +++ b/src/utils/userMgmtHelpers.ts @@ -0,0 +1,23 @@ +/** + * Render a relative time string like "3 hours ago" / "in 2 weeks" with + * the supplied locale. Navidrome returns `"0001-01-01T00:00:00Z"` for + * never-accessed users — that round-trips to year 1, which `Date.parse` + * turns into a wildly-negative epoch. We guard with a "is this after the + * year 2001-ish" sanity check and fall back to the `neverLabel` so the + * UI doesn't claim "2024 years ago". + */ +export function formatLastSeen(iso: string | null | undefined, locale: string, neverLabel: string): string { + if (!iso) return neverLabel; + const t = new Date(iso).getTime(); + 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'); +}