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

@@ -1,4 +1,4 @@
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { StatCard, type StatCardColor } from "../../ui";
@@ -121,6 +121,7 @@ const KPI_COLOR_MAP: Record<string, StatCardColor> = {
};
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
const reduce = useReducedMotion();
const kpiCards = buildKpiCards(dashData);
if (kpiCards.length === 0) {
return null;
@@ -129,8 +130,8 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
return (
<Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
sx={{
display: "grid",

View File

@@ -1,5 +1,6 @@
import { useState, useRef } from "react";
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import { useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
@@ -125,6 +126,8 @@ export default function DashProfile({
}: DashProfileProps) {
const { user, updateUser } = useAuth();
const alert = useAlert();
const queryClient = useQueryClient();
const reduce = useReducedMotion();
const totpSetupRef = useRef<HTMLInputElement>(null);
// The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and
@@ -156,21 +159,30 @@ export default function DashProfile({
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
const dataToSave = { ...formData };
if (dataToSave.new_password && !dataToSave.current_password) {
if (formData.new_password && !formData.current_password) {
alert.error("Pro změnu hesla zadejte aktuální heslo");
return;
}
if (dataToSave.current_password && !dataToSave.new_password) {
if (formData.current_password && !formData.new_password) {
alert.error("Pro změnu hesla zadejte nové heslo");
return;
}
// Strip empty password fields so Zod doesn't reject ""
if (!dataToSave.current_password)
delete (dataToSave as any).current_password;
if (!dataToSave.new_password) delete (dataToSave as any).new_password;
// Build the payload with the password fields optional so empty ones can be
// omitted (Zod rejects ""), without resorting to `as any` deletes.
const dataToSave: Partial<
Pick<ProfileFormData, "current_password" | "new_password">
> &
Omit<ProfileFormData, "current_password" | "new_password"> = {
username: formData.username,
email: formData.email,
first_name: formData.first_name,
last_name: formData.last_name,
};
if (formData.current_password)
dataToSave.current_password = formData.current_password;
if (formData.new_password) dataToSave.new_password = formData.new_password;
try {
const response = await apiFetch(`${API_BASE}/profile`, {
@@ -185,13 +197,21 @@ export default function DashProfile({
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
});
// Refresh anything keyed on the current user's data so stale views
// (dashboard widgets, user lists) pick up the edited profile.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setShowModal(false);
// The 300ms wait is load-bearing: it lets the modal's close fade finish
// before the success toast appears, so the toast doesn't flash over the
// still-fading dialog.
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success("Profil byl upraven");
} else {
alert.error(data.error || "Nepodařilo se uložit profil");
}
} catch {
} catch (err) {
console.error("DashProfile: uložení profilu selhalo", err);
alert.error("Chyba připojení");
}
};
@@ -216,8 +236,8 @@ export default function DashProfile({
return (
<>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<Card>

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import { useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext";
@@ -16,6 +17,14 @@ interface Vehicle {
name: string;
}
// Standard { success, data, error, message } envelope returned by the API.
interface ApiResult<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
interface TripForm {
vehicle_id: string;
trip_date: string;
@@ -61,6 +70,8 @@ export default function DashQuickActions({
}: DashQuickActionsProps) {
const { hasPermission } = useAuth();
const alert = useAlert();
const queryClient = useQueryClient();
const reduce = useReducedMotion();
const [showTripModal, setShowTripModal] = useState(false);
const [tripSubmitting, setTripSubmitting] = useState(false);
@@ -93,16 +104,17 @@ export default function DashQuickActions({
try {
const response = await apiFetch(`${API_BASE}/vehicles`);
const result = await response.json();
const result: ApiResult<Vehicle[] | { vehicles?: Vehicle[] }> =
await response.json();
if (result.success) {
setTripVehicles(
Array.isArray(result.data)
? result.data
: result.data?.vehicles || [],
: (result.data?.vehicles ?? []),
);
}
} catch {
// vozidla se nenacetla
} catch (e) {
console.error("DashQuickActions: nepodařilo se načíst vozidla", e);
}
};
@@ -113,12 +125,16 @@ export default function DashQuickActions({
}
try {
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
const result = await response.json();
if (result.success) {
setTripForm((prev) => ({ ...prev, start_km: result.data.last_km }));
const result: ApiResult<{ last_km: number | string }> =
await response.json();
if (result.success && result.data) {
setTripForm((prev) => ({
...prev,
start_km: String(result.data!.last_km ?? ""),
}));
}
} catch {
// last_km se nenacetlo
} catch (e) {
console.error("DashQuickActions: nepodařilo se načíst poslední km", e);
}
};
@@ -139,7 +155,7 @@ export default function DashQuickActions({
if (
tripForm.start_km &&
tripForm.end_km &&
parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)
parseInt(tripForm.end_km, 10) <= parseInt(tripForm.start_km, 10)
) {
errs.end_km = "Musí být větší než počáteční";
}
@@ -161,14 +177,19 @@ export default function DashQuickActions({
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tripForm),
});
const result = await response.json();
const result: ApiResult<unknown> = await response.json();
if (result.success) {
// A new trip changes the trip list and the dashboard vehicle widgets —
// invalidate the broad domains (prefix-matching covers sub-queries).
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
setShowTripModal(false);
alert.success(result.message);
alert.success(result.message ?? "Jízda uložena");
} else {
alert.error(result.error);
alert.error(result.error ?? "Uložení jízdy selhalo");
}
} catch {
} catch (e) {
console.error("DashQuickActions: uložení jízdy selhalo", e);
alert.error("Chyba připojení");
} finally {
setTripSubmitting(false);
@@ -176,8 +197,8 @@ export default function DashQuickActions({
};
const tripDistance = (): number => {
const s = parseInt(tripForm.start_km) || 0;
const e = parseInt(tripForm.end_km) || 0;
const s = parseInt(tripForm.start_km, 10) || 0;
const e = parseInt(tripForm.end_km, 10) || 0;
return e > s ? e - s : 0;
};
@@ -294,8 +315,8 @@ export default function DashQuickActions({
<>
<Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
sx={{
display: "grid",

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
@@ -69,6 +69,7 @@ function getDeviceIcon(iconType?: string) {
export default function DashSessions() {
const alert = useAlert();
const queryClient = useQueryClient();
const reduce = useReducedMotion();
const { data: sessions = [], isPending: sessionsLoading } =
useQuery(sessionsOptions());
@@ -128,8 +129,8 @@ export default function DashSessions() {
return (
<>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<Card sx={{ display: "flex", flexDirection: "column" }}>