diff --git a/client/src/components/Admin/AuditLogPanel.tsx b/client/src/components/Admin/AuditLogPanel.tsx index 093ebc4e3..fde48298c 100644 --- a/client/src/components/Admin/AuditLogPanel.tsx +++ b/client/src/components/Admin/AuditLogPanel.tsx @@ -1,21 +1,9 @@ -import React, { useCallback, useEffect, useState } from 'react' -import { adminApi } from '../../api/client' +import React from 'react' import { useTranslation } from '../../i18n' import { RefreshCw, ClipboardList, Loader2 } from 'lucide-react' import { fs } from '../shared/DialogShell' import { SETTINGS_BUTTON, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit' - -interface AuditEntry { - id: number - created_at: string - user_id: number | null - username: string | null - user_email: string | null - action: string - resource: string | null - details: Record | null - ip: string | null -} +import { useAuditLog } from './useAuditLog' interface AuditLogPanelProps { serverTimezone?: string @@ -26,81 +14,8 @@ const TD = 'px-3.5 py-2.5 align-top' const CODE = 'font-geist tabular-nums' export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement { - const { t, locale } = useTranslation() - const [entries, setEntries] = useState([]) - const [total, setTotal] = useState(0) - const [offset, setOffset] = useState(0) - const [loading, setLoading] = useState(true) - const limit = 100 - - const loadFirstPage = useCallback(async () => { - setLoading(true) - try { - const data = await adminApi.auditLog({ limit, offset: 0 }) as { - entries: AuditEntry[] - total: number - } - setEntries(data.entries || []) - setTotal(data.total ?? 0) - setOffset(0) - } catch { - setEntries([]) - setTotal(0) - setOffset(0) - } finally { - setLoading(false) - } - }, []) - - const loadMore = useCallback(async () => { - const nextOffset = offset + limit - setLoading(true) - try { - const data = await adminApi.auditLog({ limit, offset: nextOffset }) as { - entries: AuditEntry[] - total: number - } - setEntries((prev) => [...prev, ...(data.entries || [])]) - setTotal(data.total ?? 0) - setOffset(nextOffset) - } catch { - /* keep existing */ - } finally { - setLoading(false) - } - }, [offset]) - - useEffect(() => { - loadFirstPage() - }, [loadFirstPage]) - - const fmtTime = (iso: string) => { - try { - return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, { - dateStyle: 'short', - timeStyle: 'medium', - timeZone: serverTimezone || undefined, - }) - } catch { - return iso - } - } - - const fmtDetails = (d: Record | null) => { - if (!d || Object.keys(d).length === 0) return '—' - try { - return JSON.stringify(d) - } catch { - return '—' - } - } - - const userLabel = (e: AuditEntry) => { - if (e.username) return e.username - if (e.user_email) return e.user_email - if (e.user_id != null) return `#${e.user_id}` - return '—' - } + const { t } = useTranslation() + const { entries, total, loading, loadFirstPage, loadMore, fmtTime, fmtDetails, userLabel } = useAuditLog(serverTimezone) let body: React.ReactNode if (loading && entries.length === 0) { diff --git a/client/src/components/Admin/useAuditLog.test.ts b/client/src/components/Admin/useAuditLog.test.ts new file mode 100644 index 000000000..4a8a1a382 --- /dev/null +++ b/client/src/components/Admin/useAuditLog.test.ts @@ -0,0 +1,120 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { adminApi } from '../../api/client'; +import { type AuditEntry, auditUserLabel, fmtAuditDetails, useAuditLog } from './useAuditLog'; + +// FE-HOOK-AUDIT-001 to FE-HOOK-AUDIT-008 + +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k, locale: 'en-US' }) })); + +function entry(id: number, over: Partial = {}): AuditEntry { + return { + id, + created_at: '2025-06-01T10:30:00Z', + user_id: 5, + username: 'alice', + user_email: 'alice@example.com', + action: 'trip.create', + resource: '/trips/42', + details: null, + ip: '127.0.0.1', + ...over, + }; +} + +const auditLog = () => vi.mocked(adminApi.auditLog); + +beforeEach(() => { + vi.spyOn(adminApi, 'auditLog').mockResolvedValue({ entries: [entry(1)], total: 150 }); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('useAuditLog', () => { + it('FE-HOOK-AUDIT-001: loads the first page of 100 on mount', async () => { + const { result } = renderHook(() => useAuditLog()); + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(auditLog()).toHaveBeenCalledWith({ limit: 100, offset: 0 }); + expect(result.current.entries.map((e) => e.id)).toEqual([1]); + expect(result.current.total).toBe(150); + }); + + it('FE-HOOK-AUDIT-002: load more appends the next page and advances the offset each time', async () => { + const { result } = renderHook(() => useAuditLog()); + await waitFor(() => expect(result.current.loading).toBe(false)); + auditLog().mockResolvedValueOnce({ entries: [entry(2)], total: 150 }); + await act(() => result.current.loadMore()); + expect(auditLog()).toHaveBeenLastCalledWith({ limit: 100, offset: 100 }); + expect(result.current.entries.map((e) => e.id)).toEqual([1, 2]); + auditLog().mockResolvedValueOnce({ entries: [entry(3)], total: 150 }); + await act(() => result.current.loadMore()); + expect(auditLog()).toHaveBeenLastCalledWith({ limit: 100, offset: 200 }); + }); + + it('FE-HOOK-AUDIT-003: a failed load more keeps what is already listed and the offset', async () => { + const { result } = renderHook(() => useAuditLog()); + await waitFor(() => expect(result.current.loading).toBe(false)); + auditLog().mockRejectedValueOnce(new Error('down')); + await act(() => result.current.loadMore()); + expect(result.current.entries.map((e) => e.id)).toEqual([1]); + expect(result.current.loading).toBe(false); + auditLog().mockResolvedValueOnce({ entries: [], total: 150 }); + await act(() => result.current.loadMore()); + expect(auditLog()).toHaveBeenLastCalledWith({ limit: 100, offset: 100 }); + }); + + it('FE-HOOK-AUDIT-004: refresh starts over from offset 0 after paging', async () => { + const { result } = renderHook(() => useAuditLog()); + await waitFor(() => expect(result.current.loading).toBe(false)); + auditLog().mockResolvedValueOnce({ entries: [entry(2)], total: 150 }); + await act(() => result.current.loadMore()); + auditLog().mockResolvedValueOnce({ entries: [entry(9)], total: 1 }); + await act(() => result.current.loadFirstPage()); + expect(result.current.entries.map((e) => e.id)).toEqual([9]); + auditLog().mockResolvedValueOnce({ entries: [], total: 1 }); + await act(() => result.current.loadMore()); + expect(auditLog()).toHaveBeenLastCalledWith({ limit: 100, offset: 100 }); + }); + + it('FE-HOOK-AUDIT-005: a failed first page empties the list and the total', async () => { + auditLog().mockRejectedValue(new Error('down')); + const { result } = renderHook(() => useAuditLog()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.entries).toEqual([]); + expect(result.current.total).toBe(0); + }); + + it('FE-HOOK-AUDIT-006: a missing entries array or total reads as empty', async () => { + auditLog().mockResolvedValue({}); + const { result } = renderHook(() => useAuditLog()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.entries).toEqual([]); + expect(result.current.total).toBe(0); + }); + + it('FE-HOOK-AUDIT-007: fmtTime reads a bare timestamp as UTC and honours the server timezone', async () => { + const { result } = renderHook(() => useAuditLog('Asia/Tokyo')); + await waitFor(() => expect(result.current.loading).toBe(false)); + const expected = new Date('2025-06-01T10:30:00Z').toLocaleString('en-US', { + dateStyle: 'short', + timeStyle: 'medium', + timeZone: 'Asia/Tokyo', + }); + expect(result.current.fmtTime('2025-06-01T10:30:00')).toBe(expected); + expect(result.current.fmtTime('2025-06-01T10:30:00Z')).toBe(expected); + }); + + it('FE-HOOK-AUDIT-008: details and user labels fall back the way both shells show them', () => { + expect(fmtAuditDetails(null)).toBe('—'); + expect(fmtAuditDetails({})).toBe('—'); + expect(fmtAuditDetails({ a: 1 })).toBe('{"a":1}'); + const circular: Record = {}; + circular.self = circular; + expect(fmtAuditDetails(circular)).toBe('—'); + expect(auditUserLabel(entry(1))).toBe('alice'); + expect(auditUserLabel(entry(1, { username: null }))).toBe('alice@example.com'); + expect(auditUserLabel(entry(1, { username: null, user_email: null }))).toBe('#5'); + expect(auditUserLabel(entry(1, { username: null, user_email: null, user_id: null }))).toBe('—'); + }); +}); diff --git a/client/src/components/Admin/useAuditLog.ts b/client/src/components/Admin/useAuditLog.ts new file mode 100644 index 000000000..9000a0831 --- /dev/null +++ b/client/src/components/Admin/useAuditLog.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { adminApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; + +export interface AuditEntry { + id: number; + created_at: string; + user_id: number | null; + username: string | null; + user_email: string | null; + action: string; + resource: string | null; + details: Record | null; + ip: string | null; +} + +const PAGE_SIZE = 100; + +/** A details object as one line of JSON, or a dash when there is nothing to show. */ +export function fmtAuditDetails(d: Record | null): string { + if (!d || Object.keys(d).length === 0) return '—'; + try { + return JSON.stringify(d); + } catch { + return '—'; + } +} + +/** Who did it: the username, else the email, else the bare user id. */ +export function auditUserLabel(e: AuditEntry): string { + if (e.username) return e.username; + if (e.user_email) return e.user_email; + if (e.user_id != null) return `#${e.user_id}`; + return '—'; +} + +/** + * The audit log behind both admin shells: the first page on mount, a refresh that starts + * over and a "load more" that appends the next page. The desktop table and the phone + * cards render their own markup over it. + */ +export function useAuditLog(serverTimezone?: string) { + const { locale } = useTranslation(); + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + // Never rendered, only carried between pages, so loadMore cannot capture a page number + // that is already spent. + const offsetRef = useRef(0); + + const loadFirstPage = useCallback(async () => { + setLoading(true); + try { + const data = (await adminApi.auditLog({ limit: PAGE_SIZE, offset: 0 })) as { + entries: AuditEntry[]; + total: number; + }; + setEntries(data.entries || []); + setTotal(data.total ?? 0); + offsetRef.current = 0; + } catch { + setEntries([]); + setTotal(0); + offsetRef.current = 0; + } finally { + setLoading(false); + } + }, []); + + const loadMore = useCallback(async () => { + const nextOffset = offsetRef.current + PAGE_SIZE; + setLoading(true); + try { + const data = (await adminApi.auditLog({ limit: PAGE_SIZE, offset: nextOffset })) as { + entries: AuditEntry[]; + total: number; + }; + setEntries((prev) => [...prev, ...(data.entries || [])]); + setTotal(data.total ?? 0); + offsetRef.current = nextOffset; + } catch { + /* keep existing */ + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadFirstPage(); + }, [loadFirstPage]); + + const fmtTime = (iso: string) => { + try { + return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, { + dateStyle: 'short', + timeStyle: 'medium', + timeZone: serverTimezone || undefined, + }); + } catch { + return iso; + } + }; + + return { + entries, + total, + loading, + loadFirstPage, + loadMore, + fmtTime, + fmtDetails: fmtAuditDetails, + userLabel: auditUserLabel, + }; +} diff --git a/client/src/mobile/screens/admin/MAdminAuditLogPanel.tsx b/client/src/mobile/screens/admin/MAdminAuditLogPanel.tsx index 83a4e9024..f57ae46b1 100644 --- a/client/src/mobile/screens/admin/MAdminAuditLogPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminAuditLogPanel.tsx @@ -1,103 +1,16 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React from 'react' import { RefreshCw, ClipboardList } from 'lucide-react' -import { adminApi } from '../../../api/client' +import { useAuditLog } from '../../../components/Admin/useAuditLog' import { useTranslation } from '../../../i18n' import { MAdminButton } from './MAdminUi' -interface AuditEntry { - id: number - created_at: string - user_id: number | null - username: string | null - user_email: string | null - action: string - resource: string | null - details: Record | null - ip: string | null -} - interface AuditLogPanelProps { serverTimezone?: string } export default function MAdminAuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement { - const { t, locale } = useTranslation() - const [entries, setEntries] = useState([]) - const [total, setTotal] = useState(0) - const [loading, setLoading] = useState(true) - // Never rendered, only carried between pages — a ref keeps loadMore from - // capturing a page number that is already spent. - const offsetRef = useRef(0) - const limit = 100 - - const loadFirstPage = useCallback(async () => { - setLoading(true) - try { - const data = (await adminApi.auditLog({ limit, offset: 0 })) as { - entries: AuditEntry[] - total: number - } - setEntries(data.entries || []) - setTotal(data.total ?? 0) - offsetRef.current = 0 - } catch { - setEntries([]) - setTotal(0) - offsetRef.current = 0 - } finally { - setLoading(false) - } - }, []) - - const loadMore = useCallback(async () => { - const nextOffset = offsetRef.current + limit - setLoading(true) - try { - const data = (await adminApi.auditLog({ limit, offset: nextOffset })) as { - entries: AuditEntry[] - total: number - } - setEntries((prev) => [...prev, ...(data.entries || [])]) - setTotal(data.total ?? 0) - offsetRef.current = nextOffset - } catch { - /* keep existing */ - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - loadFirstPage() - }, [loadFirstPage]) - - const fmtTime = (iso: string) => { - try { - return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, { - dateStyle: 'short', - timeStyle: 'medium', - timeZone: serverTimezone || undefined, - }) - } catch { - return iso - } - } - - const fmtDetails = (d: Record | null) => { - if (!d || Object.keys(d).length === 0) return '—' - try { - return JSON.stringify(d) - } catch { - return '—' - } - } - - const userLabel = (e: AuditEntry) => { - if (e.username) return e.username - if (e.user_email) return e.user_email - if (e.user_id != null) return `#${e.user_id}` - return '—' - } + const { t } = useTranslation() + const { entries, total, loading, loadFirstPage, loadMore, fmtTime, fmtDetails, userLabel } = useAuditLog(serverTimezone) return (