feat(users): show last-access timestamp per user

Maps Navidrome's lastAccessAt onto NdUser and renders it in the
User Management row as a localized relative time (Intl.RelativeTimeFormat
with the current i18n language). Tooltip carries the absolute
timestamp. Navidrome returns 0001-01-01 for never-accessed users —
detected and shown as the localized "Never" label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-20 22:26:58 +02:00
parent 459c9f688d
commit 2d3be10c8f
10 changed files with 39 additions and 2 deletions
+2
View File
@@ -13,6 +13,7 @@ export interface NdUser {
isAdmin: boolean; isAdmin: boolean;
libraryIds: number[]; libraryIds: number[];
lastLoginAt?: string | null; lastLoginAt?: string | null;
lastAccessAt?: string | null;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@@ -56,6 +57,7 @@ export async function ndListUsers(serverUrl: string, token: string): Promise<NdU
isAdmin: !!o.isAdmin, isAdmin: !!o.isAdmin,
libraryIds: extractLibraryIds(o), libraryIds: extractLibraryIds(o),
lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null, lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null,
lastAccessAt: (o.lastAccessAt as string | null | undefined) ?? null,
createdAt: o.createdAt as string | undefined, createdAt: o.createdAt as string | undefined,
updatedAt: o.updatedAt as string | undefined, updatedAt: o.updatedAt as string | undefined,
}; };
+1
View File
@@ -489,6 +489,7 @@ export const deTranslation = {
userMgmtLibrariesValidation: 'Mindestens eine Bibliothek auswählen.', userMgmtLibrariesValidation: 'Mindestens eine Bibliothek auswählen.',
userMgmtLibrariesUpdateError: 'Benutzer gespeichert, aber Bibliothekszuweisung fehlgeschlagen', userMgmtLibrariesUpdateError: 'Benutzer gespeichert, aber Bibliothekszuweisung fehlgeschlagen',
userMgmtNoLibraries: 'Keine Bibliotheken zugewiesen', userMgmtNoLibraries: 'Keine Bibliotheken zugewiesen',
userMgmtNeverSeen: 'Nie',
userMgmtSave: 'Speichern', userMgmtSave: 'Speichern',
userMgmtCancel: 'Abbrechen', userMgmtCancel: 'Abbrechen',
userMgmtDelete: 'Löschen', userMgmtDelete: 'Löschen',
+1
View File
@@ -491,6 +491,7 @@ export const enTranslation = {
userMgmtLibrariesValidation: 'Select at least one library.', userMgmtLibrariesValidation: 'Select at least one library.',
userMgmtLibrariesUpdateError: 'User saved, but library assignment failed', userMgmtLibrariesUpdateError: 'User saved, but library assignment failed',
userMgmtNoLibraries: 'No libraries assigned', userMgmtNoLibraries: 'No libraries assigned',
userMgmtNeverSeen: 'Never',
userMgmtSave: 'Save', userMgmtSave: 'Save',
userMgmtCancel: 'Cancel', userMgmtCancel: 'Cancel',
userMgmtDelete: 'Delete', userMgmtDelete: 'Delete',
+1
View File
@@ -482,6 +482,7 @@ export const esTranslation = {
userMgmtLibrariesValidation: 'Selecciona al menos una biblioteca.', userMgmtLibrariesValidation: 'Selecciona al menos una biblioteca.',
userMgmtLibrariesUpdateError: 'Usuario guardado, pero la asignación de bibliotecas falló', userMgmtLibrariesUpdateError: 'Usuario guardado, pero la asignación de bibliotecas falló',
userMgmtNoLibraries: 'Sin bibliotecas asignadas', userMgmtNoLibraries: 'Sin bibliotecas asignadas',
userMgmtNeverSeen: 'Nunca',
userMgmtSave: 'Guardar', userMgmtSave: 'Guardar',
userMgmtCancel: 'Cancelar', userMgmtCancel: 'Cancelar',
userMgmtDelete: 'Eliminar', userMgmtDelete: 'Eliminar',
+1
View File
@@ -479,6 +479,7 @@ export const frTranslation = {
userMgmtLibrariesValidation: 'Sélectionnez au moins une bibliothèque.', userMgmtLibrariesValidation: 'Sélectionnez au moins une bibliothèque.',
userMgmtLibrariesUpdateError: 'Utilisateur enregistré, mais l\u2019attribution des bibliothèques a échoué', userMgmtLibrariesUpdateError: 'Utilisateur enregistré, mais l\u2019attribution des bibliothèques a échoué',
userMgmtNoLibraries: 'Aucune bibliothèque attribuée', userMgmtNoLibraries: 'Aucune bibliothèque attribuée',
userMgmtNeverSeen: 'Jamais',
userMgmtSave: 'Enregistrer', userMgmtSave: 'Enregistrer',
userMgmtCancel: 'Annuler', userMgmtCancel: 'Annuler',
userMgmtDelete: 'Supprimer', userMgmtDelete: 'Supprimer',
+1
View File
@@ -479,6 +479,7 @@ export const nbTranslation = {
userMgmtLibrariesValidation: 'Velg minst ett bibliotek.', userMgmtLibrariesValidation: 'Velg minst ett bibliotek.',
userMgmtLibrariesUpdateError: 'Brukeren ble lagret, men bibliotekstilordning mislyktes', userMgmtLibrariesUpdateError: 'Brukeren ble lagret, men bibliotekstilordning mislyktes',
userMgmtNoLibraries: 'Ingen biblioteker tilordnet', userMgmtNoLibraries: 'Ingen biblioteker tilordnet',
userMgmtNeverSeen: 'Aldri',
userMgmtSave: 'Lagre', userMgmtSave: 'Lagre',
userMgmtCancel: 'Avbryt', userMgmtCancel: 'Avbryt',
userMgmtDelete: 'Slett', userMgmtDelete: 'Slett',
+1
View File
@@ -478,6 +478,7 @@ export const nlTranslation = {
userMgmtLibrariesValidation: 'Selecteer ten minste één bibliotheek.', userMgmtLibrariesValidation: 'Selecteer ten minste één bibliotheek.',
userMgmtLibrariesUpdateError: 'Gebruiker opgeslagen, maar bibliotheektoewijzing mislukt', userMgmtLibrariesUpdateError: 'Gebruiker opgeslagen, maar bibliotheektoewijzing mislukt',
userMgmtNoLibraries: 'Geen bibliotheken toegewezen', userMgmtNoLibraries: 'Geen bibliotheken toegewezen',
userMgmtNeverSeen: 'Nooit',
userMgmtSave: 'Opslaan', userMgmtSave: 'Opslaan',
userMgmtCancel: 'Annuleren', userMgmtCancel: 'Annuleren',
userMgmtDelete: 'Verwijderen', userMgmtDelete: 'Verwijderen',
+1
View File
@@ -496,6 +496,7 @@ export const ruTranslation = {
userMgmtLibrariesValidation: 'Выберите хотя бы одну библиотеку.', userMgmtLibrariesValidation: 'Выберите хотя бы одну библиотеку.',
userMgmtLibrariesUpdateError: 'Пользователь сохранён, но не удалось назначить библиотеки', userMgmtLibrariesUpdateError: 'Пользователь сохранён, но не удалось назначить библиотеки',
userMgmtNoLibraries: 'Библиотеки не назначены', userMgmtNoLibraries: 'Библиотеки не назначены',
userMgmtNeverSeen: 'Никогда',
userMgmtSave: 'Сохранить', userMgmtSave: 'Сохранить',
userMgmtCancel: 'Отмена', userMgmtCancel: 'Отмена',
userMgmtDelete: 'Удалить', userMgmtDelete: 'Удалить',
+1
View File
@@ -474,6 +474,7 @@ export const zhTranslation = {
userMgmtLibrariesValidation: '请至少选择一个音乐库。', userMgmtLibrariesValidation: '请至少选择一个音乐库。',
userMgmtLibrariesUpdateError: '用户已保存,但音乐库分配失败', userMgmtLibrariesUpdateError: '用户已保存,但音乐库分配失败',
userMgmtNoLibraries: '未分配音乐库', userMgmtNoLibraries: '未分配音乐库',
userMgmtNeverSeen: '从未',
userMgmtSave: '保存', userMgmtSave: '保存',
userMgmtCancel: '取消', userMgmtCancel: '取消',
userMgmtDelete: '删除', userMgmtDelete: '删除',
+29 -2
View File
@@ -444,6 +444,23 @@ function 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');
}
function UserManagementSection({ function UserManagementSection({
serverUrl, serverUrl,
token, token,
@@ -453,7 +470,7 @@ function UserManagementSection({
token: string; token: string;
currentUsername: string; currentUsername: string;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [users, setUsers] = useState<NdUser[]>([]); const [users, setUsers] = useState<NdUser[]>([]);
const [libraries, setLibraries] = useState<NdLibrary[]>([]); const [libraries, setLibraries] = useState<NdLibrary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -607,6 +624,10 @@ function UserManagementSection({
: u.libraryIds.length === 0 : u.libraryIds.length === 0
? t('settings.userMgmtNoLibraries') ? t('settings.userMgmtNoLibraries')
: libraries.filter(l => u.libraryIds.includes(l.id)).map(l => l.name).join(', '); : 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 ( return (
<div <div
key={u.id} key={u.id}
@@ -652,9 +673,15 @@ function UserManagementSection({
{libNames} {libNames}
</span> </span>
)} )}
<span
style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: libNames ? 0 : 'auto' }}
data-tooltip={lastSeenAbsolute || undefined}
>
{lastSeen}
</span>
<button <button
className="btn btn-ghost" className="btn btn-ghost"
style={{ color: 'var(--danger)', padding: '2px 6px', marginLeft: 'auto', flexShrink: 0 }} style={{ color: 'var(--danger)', padding: '2px 6px', flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); setConfirmingDelete(u); }} onClick={(e) => { e.stopPropagation(); setConfirmingDelete(u); }}
disabled={busy || isSelf} disabled={busy || isSelf}
data-tooltip={t('settings.userMgmtDelete')} data-tooltip={t('settings.userMgmtDelete')}