fix: 2026-06-09 full-codebase audit hardening

Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 06:45:26 +02:00
parent c454d1a3fc
commit 519edce373
179 changed files with 7179 additions and 2844 deletions

View File

@@ -15,7 +15,13 @@ export default function Alert({
onClose?: () => void;
}) {
return (
<MuiAlert severity={severity} onClose={onClose} sx={{ borderRadius: 2 }}>
<MuiAlert
severity={severity}
onClose={onClose}
// Czech label for the close button (MUI defaults to English "Close").
slotProps={{ closeButton: { "aria-label": "Zavřít" } }}
sx={{ borderRadius: 2 }}
>
{children}
</MuiAlert>
);

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { Outlet, Navigate, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
@@ -19,14 +19,24 @@ export default function AppShell() {
const [mobileOpen, setMobileOpen] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const location = useLocation();
const logoutTimer = useRef<ReturnType<typeof setTimeout>>();
const handleLogout = useCallback(() => {
setLoggingOut(true);
setMobileOpen(false);
setLogoutAlert();
setTimeout(() => logout(), 400);
// Delay so the blur/scale exit animation can play; store the id so an
// unmount within the 400ms window cancels it (avoids logout() firing on
// an unmounted tree).
logoutTimer.current = setTimeout(() => logout(), 400);
}, [logout]);
useEffect(() => {
return () => {
if (logoutTimer.current) clearTimeout(logoutTimer.current);
};
}, []);
if (loading) {
return (
<ScopedCssBaseline>
@@ -138,6 +148,7 @@ export default function AppShell() {
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />

View File

@@ -1,10 +1,30 @@
import MuiCard, { type CardProps } from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import CardContent, { type CardContentProps } from "@mui/material/CardContent";
export default function Card({ children, ...props }: CardProps) {
export interface AppCardProps extends CardProps {
/**
* Skip the default CardContent wrapper so children sit edge-to-edge (e.g. for
* media, tables, or a custom layout). Defaults to false — the standard padded
* content behavior is unchanged for existing call sites.
*/
disableContentPadding?: boolean;
/** Props forwarded to the inner CardContent (ignored when disableContentPadding). */
contentProps?: CardContentProps;
}
export default function Card({
children,
disableContentPadding = false,
contentProps,
...props
}: AppCardProps) {
return (
<MuiCard {...props}>
<CardContent>{children}</CardContent>
{disableContentPadding ? (
children
) : (
<CardContent {...contentProps}>{children}</CardContent>
)}
</MuiCard>
);
}

View File

@@ -7,10 +7,20 @@ export function CheckboxField({
label,
checked,
onChange,
disabled,
name,
id,
indeterminate,
required,
}: {
label: ReactNode;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
name?: string;
id?: string;
indeterminate?: boolean;
required?: boolean;
}) {
return (
<FormControlLabel
@@ -18,9 +28,16 @@ export function CheckboxField({
<MuiCheckbox
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
name={name}
id={id}
indeterminate={indeterminate}
required={required}
/>
}
label={label}
disabled={disabled}
required={required}
/>
);
}

View File

@@ -41,15 +41,31 @@ export default function ConfirmDialog({
// this the button would flash back to "Smazat"/confirmText). React
// "adjust state from props during render": update only while open. Also keeps
// content from flashing empty when the caller clears its row on close.
const [shown, setShown] = useState({ title, message, confirmText, loading });
const [shown, setShown] = useState({
title,
message,
confirmText,
cancelText,
confirmVariant,
loading,
});
if (
isOpen &&
(shown.title !== title ||
shown.message !== message ||
shown.confirmText !== confirmText ||
shown.cancelText !== cancelText ||
shown.confirmVariant !== confirmVariant ||
shown.loading !== loading)
) {
setShown({ title, message, confirmText, loading });
setShown({
title,
message,
confirmText,
cancelText,
confirmVariant,
loading,
});
}
return (
@@ -66,12 +82,12 @@ export default function ConfirmDialog({
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<MuiButton onClick={onClose} color="inherit" disabled={shown.loading}>
{cancelText}
{shown.cancelText}
</MuiButton>
<MuiButton
onClick={onConfirm}
variant="contained"
color={confirmVariant === "danger" ? "error" : "primary"}
color={shown.confirmVariant === "danger" ? "error" : "primary"}
disabled={shown.loading}
>
{shown.loading ? "Zpracovávám…" : shown.confirmText}

View File

@@ -11,6 +11,9 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import TableSortLabel from "@mui/material/TableSortLabel";
import MenuItem from "@mui/material/MenuItem";
import MuiTextField from "@mui/material/TextField";
import IconButton from "@mui/material/IconButton";
export interface DataColumn<T> {
key: string;
@@ -78,8 +81,56 @@ export default function DataTable<T>({
if (isMobile) {
const actionCol = columns.find((c) => c.key === "actions");
const dataCols = columns.filter((c) => c.key !== "actions");
// The header row (with its sort labels) is gone in the card layout, so
// surface the same columns[].sortKey/onSort wiring through a compact
// dropdown + direction toggle, otherwise mobile users can't sort at all.
const sortCols = columns.filter((c) => c.sortKey);
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{onSort && sortCols.length > 0 && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<MuiTextField
select
size="small"
label="Řadit podle"
value={sortBy ?? ""}
onChange={(e) => onSort(e.target.value)}
sx={{ flex: 1 }}
>
{sortCols.map((c) => (
<MenuItem key={c.key} value={c.sortKey!}>
{c.header}
</MenuItem>
))}
</MuiTextField>
<IconButton
aria-label={
sortDir === "desc" ? "Řadit vzestupně" : "Řadit sestupně"
}
disabled={!sortBy}
onClick={() => sortBy && onSort(sortBy)}
size="small"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
style={{
transform:
sortDir === "desc" ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform .15s",
}}
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</IconButton>
</Box>
)}
{rows.map((row) => {
const extra = rowSx?.(row);
return (
@@ -255,6 +306,14 @@ export default function DataTable<T>({
<TableCell
key={c.key}
align={c.align}
// The actions cell must not bubble its click to the row: an
// action button inside an onRowClick row would otherwise
// fire both the action AND the row navigation.
onClick={
onRowClick && c.key === "actions"
? (e) => e.stopPropagation()
: undefined
}
sx={{
fontSize: ".8rem",
...(c.bold ? { fontWeight: 500 } : {}),

View File

@@ -1,4 +1,10 @@
import type { ReactNode } from "react";
import {
cloneElement,
isValidElement,
useId,
type ReactElement,
type ReactNode,
} from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import FormControlLabel from "@mui/material/FormControlLabel";
@@ -18,10 +24,44 @@ export function Field({
hint?: string;
children: ReactNode;
}) {
// Associate the <label> with the control. Generate a stable id, render it via
// `htmlFor`, and inject the same id onto the single input child so click-to-
// focus works and screen readers announce the label. Wire error/hint via
// aria-describedby + aria-invalid for assistive tech. We only inject onto a
// valid element that hasn't already set `id`, so explicit caller ids win.
const reactId = useId();
const generatedId = `field-${reactId}`;
// If the child already carries an id, keep it as the htmlFor target so the
// label still points at the real input; otherwise inject the generated one.
const childId = isValidElement(children)
? (children.props as { id?: string }).id
: undefined;
const inputId = childId ?? generatedId;
const describedById = error
? `${inputId}-error`
: hint
? `${inputId}-hint`
: undefined;
let control = children;
if (isValidElement(children)) {
const extra: Record<string, unknown> = {};
if (childId == null) extra.id = inputId;
if (describedById) extra["aria-describedby"] = describedById;
if (error) extra["aria-invalid"] = true;
if (Object.keys(extra).length > 0) {
control = cloneElement(
children as ReactElement<Record<string, unknown>>,
extra,
);
}
}
return (
<Box sx={{ mb: 2 }}>
<Typography
component="label"
htmlFor={inputId}
variant="body2"
sx={{
display: "block",
@@ -37,13 +77,13 @@ export function Field({
</Box>
)}
</Typography>
{children}
{control}
{error ? (
<Typography variant="caption" color="error">
<Typography id={describedById} variant="caption" color="error">
{error}
</Typography>
) : hint ? (
<Typography variant="caption" color="text.secondary">
<Typography id={describedById} variant="caption" color="text.secondary">
{hint}
</Typography>
) : null}
@@ -56,20 +96,24 @@ export function SwitchField({
label,
checked,
onChange,
disabled,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<FormControlLabel
control={
<Switch
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
}
label={label}
disabled={disabled}
/>
);
}

View File

@@ -19,7 +19,7 @@ export default function LoadingState({ label }: LoadingStateProps) {
gap: 2,
}}
>
<CircularProgress size={36} />
<CircularProgress size={36} aria-label={label ?? "Načítání"} />
{label && (
<Typography variant="body2" color="text.secondary">
{label}

View File

@@ -46,15 +46,22 @@ export default function Modal({
// title/submitText NOR the loading state flips during the close fade-out
// (e.g. on save, `loading` goes false a beat before the dialog finishes
// closing — without this the button would flash back to "Uložit změny").
const [shown, setShown] = useState({ title, subtitle, submitText, loading });
const [shown, setShown] = useState({
title,
subtitle,
submitText,
cancelText,
loading,
});
if (
isOpen &&
(shown.title !== title ||
shown.subtitle !== subtitle ||
shown.submitText !== submitText ||
shown.cancelText !== cancelText ||
shown.loading !== loading)
) {
setShown({ title, subtitle, submitText, loading });
setShown({ title, subtitle, submitText, cancelText, loading });
}
return (
@@ -78,7 +85,7 @@ export default function Modal({
<DialogActions sx={{ px: 3, pb: 2 }}>
{!hideCancel && (
<MuiButton onClick={onClose} color="inherit" disabled={shown.loading}>
{cancelText}
{shown.cancelText}
</MuiButton>
)}
<MuiButton

View File

@@ -5,10 +5,15 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import { cs } from "date-fns/locale";
import { theme } from "../theme";
import AppGlobalStyles from "../GlobalStyles";
import { DEFAULT_THEME_MODE } from "../../context/ThemeContext";
export default function MuiProvider({ children }: { children: ReactNode }) {
// `defaultMode` is only the first-paint fallback before a stored preference is
// read. It is pinned to ThemeContext's single `DEFAULT_THEME_MODE` constant
// (the real source of truth for `<html data-theme>` / the `mui-mode` storage
// key) so the two defaults can never drift and cause a one-frame mismatch.
return (
<ThemeProvider theme={theme} defaultMode="dark">
<ThemeProvider theme={theme} defaultMode={DEFAULT_THEME_MODE}>
<AppGlobalStyles />
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={cs}>
{children}

View File

@@ -13,10 +13,13 @@ export default function ProgressBar({
value,
color = "primary",
height = 8,
label,
}: {
value: number;
color?: ProgressColor;
height?: number;
/** Accessible name for the progress bar (announced by screen readers). */
label?: string;
}) {
const clamped = Number.isFinite(value)
? Math.max(0, Math.min(100, value))
@@ -27,6 +30,7 @@ export default function ProgressBar({
variant="determinate"
value={clamped}
color={color}
aria-label={label ?? `Průběh ${Math.round(clamped)} %`}
sx={{ height, borderRadius: height / 2 }}
/>
</Box>

View File

@@ -8,6 +8,11 @@ export interface TabDef {
label: string;
}
// Deterministic ids so the Tab control and its TabPanel can cross-reference for
// a11y (aria-controls <-> aria-labelledby). Values are app-controlled strings.
const tabId = (value: string) => `tab-${value}`;
const panelId = (value: string) => `tabpanel-${value}`;
/** Tab bar over MUI Tabs. Pair with <TabPanel> for the content. */
export function Tabs({
value,
@@ -30,7 +35,13 @@ export function Tabs({
}}
>
{tabs.map((t) => (
<Tab key={t.value} value={t.value} label={t.label} />
<Tab
key={t.value}
value={t.value}
label={t.label}
id={tabId(t.value)}
aria-controls={panelId(t.value)}
/>
))}
</MuiTabs>
);
@@ -47,5 +58,14 @@ export function TabPanel({
children: ReactNode;
}) {
if (value !== current) return null;
return <Box sx={{ pt: 2.5 }}>{children}</Box>;
return (
<Box
role="tabpanel"
id={panelId(value)}
aria-labelledby={tabId(value)}
sx={{ pt: 2.5 }}
>
{children}
</Box>
);
}

View File

@@ -634,7 +634,13 @@ export function isItemActive(item: MenuItem, pathname: string): boolean {
return true;
}
if (item.end) return pathname === item.path;
return pathname.startsWith(item.path);
// Boundary-safe prefix match: exact, or a child route under item.path (so a
// sibling like "/x-foo" can't spuriously activate "/x"). Avoids a double "/"
// when item.path is the root.
return (
pathname === item.path ||
pathname.startsWith(item.path === "/" ? "/" : `${item.path}/`)
);
}
/** Permission check: true if no permission, or any listed permission is held. */

View File

@@ -1,8 +1,9 @@
import { useEffect } from "react";
// Ref-counted lock of the <html> scroll. MUI's Dialog only locks <body>, but
// base.css sets `html { overflow-x: hidden }`, which (per the CSS spec) forces
// html's overflow-y to `auto` — making <html> the vertical scroll container.
// GlobalStyles.tsx sets `html { overflow-x: hidden }`, which (per the CSS spec)
// forces html's overflow-y to `auto` — making <html> the vertical scroll
// container.
// So body-locking alone leaves the page scrollable behind the modal. We lock
// <html> directly and pad for the removed scrollbar to avoid a layout shift.
let lockCount = 0;