v1.7.6: Bearbeiten/Löschen auf BEO-Mitglieder beschränkt

Ändern und Löschen eines Eintrags ist nur noch für angemeldete BEOs des Eintrags möglich (Admins dürfen immer). Serverseitige Prüfung via logbuch_beos-Tabelle; clientseitig werden die Aktions-Buttons nur eingeblendet, wenn der User in der BEO-Liste steht.

Außerdem: setState-in-Effect-Linterfehler in LogbuchList behoben (abgeleiteter Loading-State, abgeleitetes Page-Reset via filterKey).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 19:17:00 +02:00
parent d56ebb229d
commit 10c6554276
4 changed files with 49 additions and 25 deletions
+4
View File
@@ -139,6 +139,8 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
kuppel={activeKuppel} kuppel={activeKuppel}
refreshKey={refreshKey} refreshKey={refreshKey}
onEdit={handleEdit} onEdit={handleEdit}
currentUserKuerzel={kuerzel}
isAdmin={role?.includes('admin') ?? false}
limit={5} limit={5}
compact compact
/> />
@@ -158,6 +160,8 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
kuppel={activeKuppel} kuppel={activeKuppel}
refreshKey={refreshKey} refreshKey={refreshKey}
onEdit={handleEdit} onEdit={handleEdit}
currentUserKuerzel={kuerzel}
isAdmin={role?.includes('admin') ?? false}
limit={15} limit={15}
/> />
</div> </div>
+8 -10
View File
@@ -11,17 +11,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const logbuchId = parseInt(id); const logbuchId = parseInt(id);
try { try {
// Zugriffskontrolle: Nur Ersteller oder Admin dürfen ändern const existingRows = await query('SELECT ID FROM logbuch WHERE ID = ?', [logbuchId]) as { ID: number }[];
const existingRows = await query('SELECT created_by FROM logbuch WHERE ID = ?', [logbuchId]) as { created_by: number }[];
if (existingRows.length === 0) { if (existingRows.length === 0) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 }); return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
} }
const isAdmin = session.role?.includes('admin'); const isAdmin = session.role?.includes('admin');
const createdBy = existingRows[0].created_by; const beoRows = await query('SELECT COUNT(*) AS cnt FROM logbuch_beos WHERE LogbuchID = ? AND BeoID = ?', [logbuchId, session.beoId]) as { cnt: number }[];
const isCreator = createdBy === null || createdBy === session.beoId; const isBeo = (beoRows[0]?.cnt ?? 0) > 0;
if (!isAdmin && !isCreator) { if (!isAdmin && !isBeo) {
return NextResponse.json({ error: 'Keine Berechtigung zum Ändern dieses Eintrags' }, { status: 403 }); return NextResponse.json({ error: 'Keine Berechtigung zum Ändern dieses Eintrags' }, { status: 403 });
} }
@@ -84,17 +83,16 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
const logbuchId = parseInt(id); const logbuchId = parseInt(id);
try { try {
// Zugriffskontrolle: Nur Ersteller oder Admin dürfen löschen const existingRows = await query('SELECT ID FROM logbuch WHERE ID = ?', [logbuchId]) as { ID: number }[];
const existingRows = await query('SELECT created_by FROM logbuch WHERE ID = ?', [logbuchId]) as { created_by: number }[];
if (existingRows.length === 0) { if (existingRows.length === 0) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 }); return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
} }
const isAdmin = session.role?.includes('admin'); const isAdmin = session.role?.includes('admin');
const createdBy = existingRows[0].created_by; const beoRows = await query('SELECT COUNT(*) AS cnt FROM logbuch_beos WHERE LogbuchID = ? AND BeoID = ?', [logbuchId, session.beoId]) as { cnt: number }[];
const isCreator = createdBy === null || createdBy === session.beoId; const isBeo = (beoRows[0]?.cnt ?? 0) > 0;
if (!isAdmin && !isCreator) { if (!isAdmin && !isBeo) {
return NextResponse.json({ error: 'Keine Berechtigung zum Löschen dieses Eintrags' }, { status: 403 }); return NextResponse.json({ error: 'Keine Berechtigung zum Löschen dieses Eintrags' }, { status: 403 });
} }
+36 -14
View File
@@ -7,6 +7,8 @@ interface Props {
kuppel: Kuppel; kuppel: Kuppel;
refreshKey: number; refreshKey: number;
onEdit: (entry: LogbuchEintrag) => void; onEdit: (entry: LogbuchEintrag) => void;
currentUserKuerzel: string;
isAdmin?: boolean;
limit?: number; limit?: number;
compact?: boolean; compact?: boolean;
} }
@@ -50,35 +52,51 @@ function formatTime(dt: string): string {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`; return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, compact = false }: Props) { export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKuerzel, isAdmin = false, limit = 10, compact = false }: Props) {
const [entries, setEntries] = useState<LogbuchEintrag[]>([]); const [entries, setEntries] = useState<LogbuchEintrag[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [month, setMonth] = useState(compact ? '' : currentMonth()); const [month, setMonth] = useState(compact ? '' : currentMonth());
const [loading, setLoading] = useState(true);
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const [error, setError] = useState(''); const [deleteErr, setDeleteErr] = useState('');
const [printEntries, setPrintEntries] = useState<LogbuchEintrag[] | null>(null); const [printEntries, setPrintEntries] = useState<LogbuchEintrag[] | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [activeSearch, setActiveSearch] = useState(''); const [activeSearch, setActiveSearch] = useState('');
const printPending = useRef(false); const printPending = useRef(false);
// Derived page: auto-resets to 0 when filter deps change — no separate setState-in-effect needed
const filterKey = `${kuppel}|${refreshKey}|${month}|${activeSearch}`;
const [pageState, setPageState] = useState({ page: 0, key: filterKey });
const page = pageState.key === filterKey ? pageState.page : 0;
// Fetch result tracked by paramsKey — loading/error are derived, not set synchronously in effect
const paramsKey = `${filterKey}|${limit}|${page}`;
const [fetchResult, setFetchResult] = useState<{ ok: boolean; forParams: string } | null>(null);
const fetchError = fetchResult?.forParams === paramsKey && !fetchResult.ok ? 'Fehler beim Laden.' : '';
const loading = !fetchError && fetchResult?.forParams !== paramsKey;
const error = fetchError || deleteErr;
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setActiveSearch(search.trim()), 300); const t = setTimeout(() => setActiveSearch(search.trim()), 300);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [search]); }, [search]);
useEffect(() => { setPage(0); }, [kuppel, refreshKey, month, activeSearch]);
useEffect(() => { useEffect(() => {
setLoading(true); let cancelled = false;
const offset = page * limit; const offset = page * limit;
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}&offset=${offset}` + const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}&offset=${offset}` +
(activeSearch ? `&search=${encodeURIComponent(activeSearch)}` : (month ? `&month=${encodeURIComponent(month)}` : '')); (activeSearch ? `&search=${encodeURIComponent(activeSearch)}` : (month ? `&month=${encodeURIComponent(month)}` : ''));
fetch(url) fetch(url)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => { setEntries(data.entries); setTotal(data.total); setLoading(false); }) .then((data) => {
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); }); if (!cancelled) {
setEntries(data.entries);
setTotal(data.total);
setFetchResult({ ok: true, forParams: paramsKey });
setDeleteErr('');
}
})
.catch(() => { if (!cancelled) setFetchResult({ ok: false, forParams: paramsKey }); });
return () => { cancelled = true; };
}, [kuppel, refreshKey, limit, page, month, activeSearch]); }, [kuppel, refreshKey, limit, page, month, activeSearch]);
useEffect(() => { useEffect(() => {
@@ -109,7 +127,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
setEntries((prev) => prev.filter((e) => e.ID !== id)); setEntries((prev) => prev.filter((e) => e.ID !== id));
setTotal((t) => t - 1); setTotal((t) => t - 1);
} catch { } catch {
setError('Fehler beim Löschen.'); setDeleteErr('Fehler beim Löschen.');
} finally { } finally {
setDeleteId(null); setDeleteId(null);
} }
@@ -270,8 +288,12 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
</td> </td>
)} )}
<td className={`${cell} text-center whitespace-nowrap print:hidden`}> <td className={`${cell} text-center whitespace-nowrap print:hidden`}>
<button onClick={() => onEdit(e)} className="text-blue-600 hover:text-blue-800 mr-2 font-medium"></button> {(isAdmin || e.BEOs?.split(', ').includes(currentUserKuerzel)) && (
<button onClick={() => setDeleteId(e.ID)} className="text-red-600 hover:text-red-800 font-medium"></button> <>
<button onClick={() => onEdit(e)} className="text-blue-600 hover:text-blue-800 mr-2 font-medium"></button>
<button onClick={() => setDeleteId(e.ID)} className="text-red-600 hover:text-red-800 font-medium"></button>
</>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -282,13 +304,13 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
{!compact && total > limit && ( {!compact && total > limit && (
<div className="flex items-center justify-center gap-3 mt-3 print:hidden"> <div className="flex items-center justify-center gap-3 mt-3 print:hidden">
<button <button
onClick={() => setPage((p) => p - 1)} onClick={() => setPageState({ page: page - 1, key: filterKey })}
disabled={page === 0} disabled={page === 0}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed" className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
> Zurück</button> > Zurück</button>
<span className="text-sm text-gray-600">Seite {page + 1} von {Math.ceil(total / limit)}</span> <span className="text-sm text-gray-600">Seite {page + 1} von {Math.ceil(total / limit)}</span>
<button <button
onClick={() => setPage((p) => p + 1)} onClick={() => setPageState({ page: page + 1, key: filterKey })}
disabled={(page + 1) * limit >= total} disabled={(page + 1) * limit >= total}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed" className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
>Weiter </button> >Weiter </button>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.7.5", "version": "1.7.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",