mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(admin): share the audit log loading and paging between desktop and phone
Both audit log panels now read one useAuditLog hook for the first page, refresh, load more and the row labels. The desktop table and the phone cards keep their own markup.
This commit is contained in:
@@ -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<string, unknown> | 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<AuditEntry[]>([])
|
||||
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<string, unknown> | 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) {
|
||||
|
||||
@@ -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> = {}): 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<string, unknown> = {};
|
||||
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('—');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<AuditEntry[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> | null
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
interface AuditLogPanelProps {
|
||||
serverTimezone?: string
|
||||
}
|
||||
|
||||
export default function MAdminAuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
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<string, unknown> | 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 (
|
||||
<div className="space-y-3">
|
||||
|
||||
Reference in New Issue
Block a user