Files
app/src/admin/ui/Modal.tsx
BOHA 519edce373 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>
2026-06-09 06:45:26 +02:00

102 lines
3.1 KiB
TypeScript

import { useState, type ReactNode } from "react";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Typography from "@mui/material/Typography";
import MuiButton from "@mui/material/Button";
import useDialogScrollLock from "./useDialogScrollLock";
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
title: string;
subtitle?: string;
children: ReactNode;
loading?: boolean;
submitDisabled?: boolean;
submitText?: string;
cancelText?: string;
/** Hide the secondary cancel button (e.g. modals whose only action closes). */
hideCancel?: boolean;
maxWidth?: "xs" | "sm" | "md" | "lg";
}
/** Form modal over MUI Dialog. Preserves the legacy FormModal prop shape. */
export default function Modal({
isOpen,
onClose,
onSubmit,
title,
subtitle,
children,
loading = false,
submitDisabled = false,
submitText = "Uložit",
cancelText = "Zrušit",
hideCancel = false,
maxWidth = "sm",
}: ModalProps) {
useDialogScrollLock(isOpen);
// Freeze the shown title/subtitle/submitText while closing, so the fade-out
// never flashes the "create" variant when the caller clears `editingUser` on
// close (React derive-state-from-props: update only while open).
// Freeze label-affecting props (incl. `loading`) while open, so neither the
// 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,
cancelText,
loading,
});
if (
isOpen &&
(shown.title !== title ||
shown.subtitle !== subtitle ||
shown.submitText !== submitText ||
shown.cancelText !== cancelText ||
shown.loading !== loading)
) {
setShown({ title, subtitle, submitText, cancelText, loading });
}
return (
<Dialog
open={isOpen}
onClose={shown.loading ? undefined : onClose}
fullWidth
maxWidth={maxWidth}
disableScrollLock
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
>
<DialogTitle sx={{ pb: shown.subtitle ? 0.5 : 1.5 }}>
{shown.title}
{shown.subtitle && (
<Typography variant="body2" color="text.secondary">
{shown.subtitle}
</Typography>
)}
</DialogTitle>
<DialogContent>{children}</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
{!hideCancel && (
<MuiButton onClick={onClose} color="inherit" disabled={shown.loading}>
{shown.cancelText}
</MuiButton>
)}
<MuiButton
onClick={onSubmit}
variant="contained"
disabled={shown.loading || submitDisabled}
>
{shown.loading ? "Ukládám…" : shown.submitText}
</MuiButton>
</DialogActions>
</Dialog>
);
}