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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user