security: fix all Medium findings from FLAWS_REPORT audit

- Auth: TOTP replay protection with counter tracking, constant-time
  backup code comparison, atomic lockout increment, per-token logout
- Invoices/PDFs: net-based VAT calculation, dangerous URL scheme
  stripping in cleanQuillHtml, orders-pdf error handling
- Orders: reject item changes on status transition, cascading
  delete cleanup, take:1 with orderBy
- Projects: atomic rename collision handling, MIME/extension
  validation, empty customer name rejection
- Attendance: Czech public holiday awareness in frontend fund
  calculation, leave_hours 0 handling, invalid date NaN guard,
  bounded per-month queries in workfund
- Users/Admin: profile audit logging + password validation, session
  revocation guard, session ID validation, dashboard DB aggregation,
  soft-deleted record protection in scope templates
- Frontend: FormField label linkage, Pagination ARIA, error
  handling in OrderConfirmationModal, 401 propagation, GPS emoji
  hidden from screen readers, table sort state fix, geolocation
  race/abort cleanup, Leaflet popup DOM safety, Vehicles toggleActive
  minimal body, CompanySettings ref mutation fix, OfferDetail unlock
  abort, AttendanceBalances combined fetches
- Utils: env validation, Puppeteer concurrency mutex, invoice alert
  cron cleanup on shutdown, body limit alignment, TOTP error logging,
  trustProxy from env, symlink rejection, rate cache Map usage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-24 08:24:14 +02:00
parent 528e55991b
commit 4f4b12f039
33 changed files with 442 additions and 211 deletions

View File

@@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { Link } from "react-router-dom";
import {
formatDate,
formatDatetime,
@@ -64,20 +64,26 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
let h: number,
m: number,
isActive = false;
let durationValid = true;
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0;
} else {
isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.max(
0,
Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
),
);
h = Math.floor(mins / 60);
m = mins % 60;
const start = log.started_at ? new Date(log.started_at) : null;
if (start && !isNaN(start.getTime()) && !isNaN(end.getTime())) {
const mins = Math.max(
0,
Math.floor((end.getTime() - start.getTime()) / 60000),
);
h = Math.floor(mins / 60);
m = mins % 60;
} else {
durationValid = false;
h = 0;
m = 0;
}
}
return (
<span
@@ -89,8 +95,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`} ({h}:
{String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""})
{log.project_name || `#${log.project_id}`} {durationValid ? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})` : "—"}
</span>
);
})}
@@ -118,7 +123,7 @@ export default function AttendanceShiftTable({
if (records.length === 0) {
return (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
<p>Za tento mĭc nejsou žádné záznamy.</p>
</div>
);
}
@@ -129,15 +134,15 @@ export default function AttendanceShiftTable({
<thead>
<tr>
<th>Datum</th>
<th>Zaměstnanec</th>
<th>ZamÄstnanec</th>
<th>Typ</th>
<th>Příchod</th>
<th>PĹĂ­chod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekt</th>
<th>GPS</th>
<th>Poznámka</th>
<th>Poznámka</th>
<th>Akce</th>
</tr>
</thead>
@@ -146,7 +151,8 @@ export default function AttendanceShiftTable({
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
60
: calculateWorkMinutes(record);
const hasLocation =
(record.arrival_lat && record.arrival_lng) ||
@@ -186,7 +192,7 @@ export default function AttendanceShiftTable({
title="Zobrazit polohu"
aria-label="Zobrazit polohu"
>
{"\uD83D\uDCCD"}
<span aria-hidden="true">{"\uD83D\uDCCD"}</span>
</Link>
) : (
"\u2014"
@@ -251,3 +257,4 @@ export default function AttendanceShiftTable({
</div>
);
}

View File

@@ -1,4 +1,10 @@
import type { CSSProperties, ReactNode } from "react";
import {
type CSSProperties,
type ReactNode,
isValidElement,
cloneElement,
useId,
} from "react";
interface FormFieldProps {
label: ReactNode;
@@ -15,13 +21,22 @@ export default function FormField({
required,
style,
}: FormFieldProps) {
const generatedId = useId();
const childProps = isValidElement(children)
? (children.props as Record<string, unknown>)
: null;
const childId = childProps?.id ? String(childProps.id) : generatedId;
const childWithId = isValidElement(children)
? cloneElement(children, { id: childId } as React.Attributes)
: children;
return (
<div className="admin-form-group" style={style}>
<label className="admin-form-label">
<label className="admin-form-label" htmlFor={childId}>
{label}
{required && <span className="admin-form-required"> *</span>}
</label>
{children}
{childWithId}
{error && <span className="admin-form-error">{error}</span>}
</div>
);

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useAlert } from "../context/AlertContext";
interface ConfirmationItem {
description: string;
@@ -33,6 +34,7 @@ export default function OrderConfirmationModal({
defaultVatRate,
applyVat,
}: OrderConfirmationModalProps) {
const alert = useAlert();
const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs");
const [applyVatState, setApplyVatState] = useState(applyVat);
@@ -43,6 +45,9 @@ export default function OrderConfirmationModal({
setLoading(true);
try {
await onGenerate(lang, applyVatState, undefined);
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
setStep("choose");
@@ -54,6 +59,9 @@ export default function OrderConfirmationModal({
setLoading(true);
try {
await onGenerate(lang, applyVatState, items);
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
setStep("choose");

View File

@@ -36,13 +36,18 @@ export default function Pagination({
};
return (
<div className="admin-pagination">
<div
className="admin-pagination"
role="navigation"
aria-label="Stránkování"
>
<div className="admin-pagination-info">{total} záznamů</div>
<div className="admin-pagination-controls">
<button
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
className="admin-pagination-page"
aria-label="Předchozí stránka"
>
<svg
width="16"
@@ -65,6 +70,8 @@ export default function Pagination({
key={p}
onClick={() => onPageChange(p)}
className={`admin-pagination-page ${p === page ? "active" : ""}`}
aria-label={`Stránka ${p}`}
aria-current={p === page ? "page" : undefined}
>
{p}
</button>
@@ -74,6 +81,7 @@ export default function Pagination({
disabled={page >= total_pages}
onClick={() => onPageChange(page + 1)}
className="admin-pagination-page"
aria-label="Další stránka"
>
<svg
width="16"