Public Access
feat(library): add game links, auditor queue, audit log and filter
LinkGameDialog (user request or auditor link), EditionsCard on the details page, LinkQueuePage and AuditLogPage for auditors with paging, hideDuplicates filter and editions badge in the library, audit menu entries, de/en texts.
This commit is contained in:
1 parent
80259cb0e9
commit
f71e8f7aff
21 files changed
+992
-11
No files matched your search
@@ -0,0 +1,123 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ChevronLeft, ChevronRight, Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {fetchAuditLog} from '../utils/links.js';
|
||||
import {formatDateTime} from '../utils/format.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS'];
|
||||
const ALL = 'ALL';
|
||||
|
||||
function GameRef({appid, name, fallback}) {
|
||||
if (appid == null) return null;
|
||||
return <Link to={`/library/${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
|
||||
}
|
||||
|
||||
/**
|
||||
* AUDITOR or higher: the newest rows of Core's audit log (merge, link, unlink, dismiss). Core checks the role.
|
||||
*/
|
||||
export default function AuditLogPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [action, setAction] = useState(ALL);
|
||||
const [page, setPage] = useState(0);
|
||||
const [result, setResult] = useState({content: [], totalPages: 0});
|
||||
const [status, setStatus] = useState('loading');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchAuditLog({action: action === ALL ? '' : action, page, size: PAGE_SIZE});
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setResult(await response.json());
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
console.error('Audit log error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [action, page]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const onActionChange = (value) => {
|
||||
setAction(value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
if (status === 'forbidden') {
|
||||
return (
|
||||
<PageContainer className="px-4 py-6 md:px-8">
|
||||
<p className="text-sm text-muted-foreground">{t('AUDIT.NO_ACCESS')}</p>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle>{t('AUDIT.TITLE')}</CardTitle>
|
||||
<Select value={action} onValueChange={onActionChange}>
|
||||
<SelectTrigger className="w-52" aria-label={t('AUDIT.ACTION.LABEL')}>
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t('AUDIT.ACTION.ALL')}</SelectItem>
|
||||
{ACTIONS.map((key) => <SelectItem key={key} value={key}>{t(`AUDIT.ACTION.${key}`)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||
{status === 'error' && <p className="text-sm text-destructive">{t('AUDIT.LOAD_ERROR')}</p>}
|
||||
{status === 'ready' && result.content.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('AUDIT.EMPTY')}</p>
|
||||
)}
|
||||
{status === 'ready' && result.content.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{result.content.map((entry) => (
|
||||
<li key={entry.id} className="space-y-1 py-3 text-sm">
|
||||
<p className="flex flex-wrap items-center gap-x-2">
|
||||
<span className="text-xs text-muted-foreground">{formatDateTime(entry.createdAt, i18n.language)}</span>
|
||||
<span>{entry.actorName ?? t('AUDIT.USER_ID', {id: entry.actorUserId})}</span>
|
||||
<span className="font-medium">{t(`AUDIT.ACTION.${entry.action}`)}</span>
|
||||
<GameRef appid={entry.appid} name={entry.gameName} fallback={entry.detail}/>
|
||||
<GameRef appid={entry.targetAppid} name={entry.targetGameName} fallback={entry.targetAppid}/>
|
||||
</p>
|
||||
{entry.detail && <p className="break-words text-xs text-muted-foreground">{entry.detail}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{status === 'ready' && result.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<Button type="button" variant="outline" size="sm" disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
|
||||
</Button>
|
||||
<span>{t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={page + 1 >= result.totalPages}
|
||||
onClick={() => setPage(page + 1)}>
|
||||
{t('ADMIN.NEXT')}<ChevronRight/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user