mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ export const ruTranslation = {
|
|||||||
userMgmtLibrariesValidation: 'Выберите хотя бы одну библиотеку.',
|
userMgmtLibrariesValidation: 'Выберите хотя бы одну библиотеку.',
|
||||||
userMgmtLibrariesUpdateError: 'Пользователь сохранён, но не удалось назначить библиотеки',
|
userMgmtLibrariesUpdateError: 'Пользователь сохранён, но не удалось назначить библиотеки',
|
||||||
userMgmtNoLibraries: 'Библиотеки не назначены',
|
userMgmtNoLibraries: 'Библиотеки не назначены',
|
||||||
|
userMgmtNeverSeen: 'Никогда',
|
||||||
userMgmtSave: 'Сохранить',
|
userMgmtSave: 'Сохранить',
|
||||||
userMgmtCancel: 'Отмена',
|
userMgmtCancel: 'Отмена',
|
||||||
userMgmtDelete: 'Удалить',
|
userMgmtDelete: 'Удалить',
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ export const zhTranslation = {
|
|||||||
userMgmtLibrariesValidation: '请至少选择一个音乐库。',
|
userMgmtLibrariesValidation: '请至少选择一个音乐库。',
|
||||||
userMgmtLibrariesUpdateError: '用户已保存,但音乐库分配失败',
|
userMgmtLibrariesUpdateError: '用户已保存,但音乐库分配失败',
|
||||||
userMgmtNoLibraries: '未分配音乐库',
|
userMgmtNoLibraries: '未分配音乐库',
|
||||||
|
userMgmtNeverSeen: '从未',
|
||||||
userMgmtSave: '保存',
|
userMgmtSave: '保存',
|
||||||
userMgmtCancel: '取消',
|
userMgmtCancel: '取消',
|
||||||
userMgmtDelete: '删除',
|
userMgmtDelete: '删除',
|
||||||
|
|||||||
+29
-2
@@ -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')}
|
||||||
|
|||||||
Reference in New Issue
Block a user