fix: audit fix pass #1 — all 19 verified HIGH findings + critical dep cleanup

Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md

- attendance: schemas accept the combined local datetimes the forms/service
  use (new dateTimeString helpers in schemas/common.ts), breaks persist on
  create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
  enrollment QR generated locally via qrcode (CSP-blocked external service
  also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
  coercion in formatters.ts), billing_text persists on update, issued-order
  status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
  (shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
  PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
  escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
  downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
  longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
  works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
  @hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
  the Prisma 6 downgrade), drop deprecated @types stubs

Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 09:59:47 +02:00
parent d08e55a41a
commit 1826fc7976
44 changed files with 2812 additions and 622 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
@@ -9,11 +9,15 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import {
tripListOptions,
tripStatsOptions,
tripVehiclesOptions,
tripUsersOptions,
type BackendTrip,
type TripStats,
} from "../lib/queries/trips";
import { companySettingsOptions } from "../lib/queries/settings";
import { jsonQuery } from "../lib/apiAdapter";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import { useApiMutation } from "../lib/queries/mutations";
@@ -28,6 +32,7 @@ import {
Select,
DateField,
MonthField,
Pagination,
StatusChip,
FilterBar,
LoadingState,
@@ -83,6 +88,190 @@ function mapTrip(bt: BackendTrip): Trip {
};
}
/**
* Escape user-origin strings before interpolating them into the print HTML
* (the JSX hidden-div approach escaped automatically; the string template
* must do it explicitly).
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
const PRINT_STYLES = `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 10mm;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.summary {
display: flex;
justify-content: space-around;
margin-bottom: 15px;
padding: 10px;
background: #f5f5f5;
border: 1px solid #ddd;
}
.summary-item { text-align: center; }
.summary-value { font-size: 14px; font-weight: 700; }
.summary-label { font-size: 9px; color: #666; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
td { font-size: 9px; }
tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
tfoot td { background: #eee; font-weight: 600; }
.badge {
display: inline-block;
padding: 1px 4px;
border-radius: 2px;
font-size: 8px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
@media print {
body { padding: 5mm; }
@page { size: A4 landscape; margin: 5mm; }
thead { display: table-header-group; }
}
`;
/**
* Build the full print document from the fetched /trips/print data set —
* no hidden-div/innerHTML round-trip, so there is nothing to race against
* React's commit.
*/
function buildPrintHtml(opts: {
trips: Trip[];
totals: TripStats;
periodName: string;
companyName: string;
vehicleName: string | null;
userName: string | null;
logoUrl: string;
}): string {
const { trips, totals } = opts;
const rows = trips
.map(
(trip) => `
<tr>
<td>${formatDate(trip.trip_date)}</td>
<td>${escapeHtml(trip.driver_name)}</td>
<td>${escapeHtml(trip.spz)}</td>
<td>${escapeHtml(trip.route_from)} &rarr; ${escapeHtml(trip.route_to)}</td>
<td class="text-right">${formatKm(trip.start_km)} - ${formatKm(trip.end_km)}</td>
<td class="text-right"><strong>${formatKm(trip.distance)} km</strong></td>
<td class="text-center">
<span class="badge ${trip.is_business ? "badge-success" : "badge-warning"}">
${trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>${escapeHtml(trip.notes || "")}</td>
</tr>`,
)
.join("");
return `
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kniha jízd - ${escapeHtml(opts.periodName)}</title>
<style>${PRINT_STYLES}</style>
</head>
<body>
<div class="print-header">
<div class="print-header-left">
<img src="${opts.logoUrl}" alt="" class="print-logo" />
<div class="print-header-text">
<h1>KNIHA JÍZD</h1>
<div class="company">${escapeHtml(opts.companyName)}</div>
</div>
</div>
<div class="print-header-right">
<div class="period">${escapeHtml(opts.periodName)}</div>
${opts.vehicleName ? `<div class="filters">Vozidlo: ${escapeHtml(opts.vehicleName)}</div>` : ""}
${opts.userName ? `<div class="filters">Řidič: ${escapeHtml(opts.userName)}</div>` : ""}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
</div>
</div>
<div class="summary">
<div class="summary-item">
<div class="summary-value">${totals.count}</div>
<div class="summary-label">Počet jízd</div>
</div>
<div class="summary-item">
<div class="summary-value">${formatKm(totals.total_km)} km</div>
<div class="summary-label">Celkem</div>
</div>
<div class="summary-item">
<div class="summary-value">${formatKm(totals.business_km)} km</div>
<div class="summary-label">Služební</div>
</div>
<div class="summary-item">
<div class="summary-value">${formatKm(totals.private_km)} km</div>
<div class="summary-label">Soukromé</div>
</div>
</div>
<table>
<thead>
<tr>
<th style="width: 70px">Datum</th>
<th style="width: 80px">Řidič</th>
<th style="width: 70px">Vozidlo</th>
<th>Trasa</th>
<th style="width: 70px" class="text-right">Stav km</th>
<th style="width: 60px" class="text-right">Vzdálenost</th>
<th style="width: 55px" class="text-center">Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td colspan="5" class="text-right">Celkem:</td>
<td class="text-right"><strong>${formatKm(totals.total_km)} km</strong></td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</body>
</html>
`;
}
const PrintIcon = (
<svg
width="18"
@@ -188,7 +377,7 @@ export default function TripsAdmin() {
});
const [filterVehicleId, setFilterVehicleId] = useState("");
const [filterUserId, setFilterUserId] = useState("");
const printRef = useRef<HTMLDivElement>(null);
const [page, setPage] = useState(1);
const [showEditModal, setShowEditModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
@@ -217,16 +406,27 @@ export default function TripsAdmin() {
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name ?? "";
const { data: tripsData, isPending } = useQuery(
tripListOptions({
month: Number(filterPeriod.slice(5, 7)) || undefined,
year: Number(filterPeriod.slice(0, 4)) || undefined,
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
userId: filterUserId ? Number(filterUserId) : undefined,
perPage: 100,
}),
);
const trips = (tripsData ?? []).map(mapTrip);
// ONE shared filter object for the list, stats AND print requests so the
// three can never drift apart. An empty MonthField value yields
// month/year = undefined (omitted from the request), mirroring the
// `Number(...) || undefined` guard the queries always used.
const filters = {
month: Number(filterPeriod.slice(5, 7)) || undefined,
year: Number(filterPeriod.slice(0, 4)) || undefined,
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
userId: filterUserId ? Number(filterUserId) : undefined,
};
const {
items: tripsItems,
pagination,
isPending,
} = usePaginatedQuery<BackendTrip>(tripListOptions({ ...filters, page }));
const trips = tripsItems.map(mapTrip);
// Stat cards: totals over the WHOLE filtered set (server-side aggregate),
// not just the visible page.
const { data: stats } = useQuery(tripStatsOptions(filters));
// useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema (UpdateTripSchema) shape directly —
@@ -318,10 +518,12 @@ export default function TripsAdmin() {
};
const getPeriodName = () =>
new Date(
Number(filterPeriod.slice(0, 4)),
Number(filterPeriod.slice(5, 7)) - 1,
).toLocaleString("cs-CZ", { month: "long", year: "numeric" });
filterPeriod
? new Date(
Number(filterPeriod.slice(0, 4)),
Number(filterPeriod.slice(5, 7)) - 1,
).toLocaleString("cs-CZ", { month: "long", year: "numeric" })
: "Všechna období";
const getSelectedVehicleName = () => {
if (!filterVehicleId) return null;
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
@@ -333,94 +535,61 @@ export default function TripsAdmin() {
return u?.name || null;
};
const handlePrint = () => {
const periodName = getPeriodName();
const handlePrint = async () => {
// Open the print window SYNCHRONOUSLY, inside the click's transient
// activation — calling window.open after the network await can fall
// outside the activation window and get popup-blocked (which previously
// failed silently).
const printWindow = window.open("", "_blank");
if (!printWindow) {
alert.error("Tisk byl zablokován prohlížečem");
return;
}
setTimeout(() => {
if (printRef.current) {
const content = printRef.current.innerHTML;
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kniha jízd - ${periodName}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 10mm;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.summary {
display: flex;
justify-content: space-around;
margin-bottom: 15px;
padding: 10px;
background: #f5f5f5;
border: 1px solid #ddd;
}
.summary-item { text-align: center; }
.summary-value { font-size: 14px; font-weight: 700; }
.summary-label { font-size: 9px; color: #666; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
td { font-size: 9px; }
tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
tfoot td { background: #eee; font-weight: 600; }
.badge {
display: inline-block;
padding: 1px 4px;
border-radius: 2px;
font-size: 8px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
@media print {
body { padding: 5mm; }
@page { size: A4 landscape; margin: 5mm; }
thead { display: table-header-group; }
}
</style>
</head>
<body>
${content}
</body>
</html>
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
}
}, 100);
// Fetch the FULL filtered set for the printout — the list query is
// paginated, so printing from it would truncate to the visible page.
let printTrips: Trip[];
let printTotals: TripStats;
try {
const params = new URLSearchParams();
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
const data = await jsonQuery<{ trips: BackendTrip[]; totals: TripStats }>(
`${API_BASE}/trips/print?${params.toString()}`,
);
printTrips = data.trips.map(mapTrip);
printTotals = data.totals;
} catch (e) {
printWindow.close();
console.error("Trip print data fetch failed:", e);
alert.error(e instanceof Error ? e.message : "Chyba připojení");
return;
}
// The user closed the popup while the data was loading — a deliberate
// cancel, not an error.
if (printWindow.closed) return;
// The document is built directly from the fetched data (no hidden-div /
// setTimeout round-trip), so it can be neither unmounted nor stale.
printWindow.document.write(
buildPrintHtml({
trips: printTrips,
totals: printTotals,
periodName: getPeriodName(),
companyName,
vehicleName: getSelectedVehicleName(),
userName: getSelectedUserName(),
logoUrl: `${window.location.origin}/api/admin/company-settings/logo?variant=light`,
}),
);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
};
const calculateDistance = (): number => {
@@ -430,11 +599,9 @@ export default function TripsAdmin() {
};
const totals = {
count: trips.length,
total: trips.reduce((sum, t) => sum + t.distance, 0),
business: trips
.filter((t) => Number(t.is_business))
.reduce((sum, t) => sum + t.distance, 0),
count: stats?.count ?? 0,
total: stats?.total_km ?? 0,
business: stats?.business_km ?? 0,
};
const columns: DataColumn<Trip>[] = [
@@ -541,7 +708,7 @@ export default function TripsAdmin() {
>
<Typography variant="h4">Správa knihy jízd</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{trips.length > 0 && (
{(pagination?.total ?? 0) > 0 && (
<Button
variant="outlined"
color="inherit"
@@ -566,12 +733,21 @@ export default function TripsAdmin() {
{/* Filters */}
<FilterBar>
<Box sx={{ flex: "0 0 180px" }}>
<MonthField value={filterPeriod} onChange={setFilterPeriod} />
<MonthField
value={filterPeriod}
onChange={(val) => {
setFilterPeriod(val);
setPage(1);
}}
/>
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={filterVehicleId}
onChange={setFilterVehicleId}
onChange={(value) => {
setFilterVehicleId(value);
setPage(1);
}}
options={[
{ value: "", label: "Všechna vozidla" },
...vehicles.map((v) => ({
@@ -584,7 +760,10 @@ export default function TripsAdmin() {
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={filterUserId}
onChange={setFilterUserId}
onChange={(value) => {
setFilterUserId(value);
setPage(1);
}}
options={[
{ value: "", label: "Všichni řidiči" },
...tripUsers.map((u) => ({
@@ -632,14 +811,21 @@ export default function TripsAdmin() {
{isPending ? (
<LoadingState />
) : (
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
<>
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</>
)}
</Card>
@@ -796,120 +982,6 @@ export default function TripsAdmin() {
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
{/* Hidden Print Content */}
{trips.length > 0 && (
<div ref={printRef} style={{ display: "none" }}>
<div className="print-header">
<div className="print-header-left">
<img
src="/api/admin/company-settings/logo?variant=light"
alt=""
className="print-logo"
/>
<div className="print-header-text">
<h1>KNIHA JÍZD</h1>
<div className="company">{companyName}</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{getPeriodName()}</div>
{getSelectedVehicleName() && (
<div className="filters">
Vozidlo: {getSelectedVehicleName()}
</div>
)}
{getSelectedUserName() && (
<div className="filters">Řidič: {getSelectedUserName()}</div>
)}
<div className="generated">
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
</div>
</div>
</div>
<div className="summary">
<div className="summary-item">
<div className="summary-value">{totals.count}</div>
<div className="summary-label">Počet jízd</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.total)} km</div>
<div className="summary-label">Celkem</div>
</div>
<div className="summary-item">
<div className="summary-value">
{formatKm(totals.business)} km
</div>
<div className="summary-label">Služební</div>
</div>
<div className="summary-item">
<div className="summary-value">
{formatKm(totals.total - totals.business)} km
</div>
<div className="summary-label">Soukromé</div>
</div>
</div>
<table>
<thead>
<tr>
<th style={{ width: "70px" }}>Datum</th>
<th style={{ width: "80px" }}>Řidič</th>
<th style={{ width: "70px" }}>Vozidlo</th>
<th>Trasa</th>
<th style={{ width: "70px" }} className="text-right">
Stav km
</th>
<th style={{ width: "60px" }} className="text-right">
Vzdálenost
</th>
<th style={{ width: "55px" }} className="text-center">
Typ
</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver_name}</td>
<td>{trip.spz}</td>
<td>
{trip.route_from} &rarr; {trip.route_to}
</td>
<td className="text-right">
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</td>
<td className="text-right">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td className="text-center">
<span
className={`badge ${trip.is_business ? "badge-success" : "badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>{trip.notes || ""}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="text-right">
Celkem:
</td>
<td className="text-right">
<strong>{formatKm(totals.total)} km</strong>
</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
)}
</PageEnter>
);
}