Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
- attendance: schemas accept the combined local datetimes the forms/service
use (new dateTimeString helpers in schemas/common.ts), breaks persist on
create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
enrollment QR generated locally via qrcode (CSP-blocked external service
also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
coercion in formatters.ts), billing_text persists on update, issued-order
status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
(shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
@hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
the Prisma 6 downgrade), drop deprecated @types stubs
Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
530 lines
17 KiB
TypeScript
530 lines
17 KiB
TypeScript
import { useState, useCallback } from "react";
|
|
import { Link as RouterLink } from "react-router-dom";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import Box from "@mui/material/Box";
|
|
import Typography from "@mui/material/Typography";
|
|
import CircularProgress from "@mui/material/CircularProgress";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import { useAlert } from "../context/AlertContext";
|
|
import apiFetch from "../utils/api";
|
|
import { dashboardOptions } from "../lib/queries/dashboard";
|
|
import { totpStatusOptions } from "../lib/queries/settings";
|
|
import { getCzechDate } from "../utils/dashboardHelpers";
|
|
import { useApiMutation } from "../lib/queries/mutations";
|
|
import { Card, Button, StatusChip, PageEnter } from "../ui";
|
|
import { iconBadgeSx } from "../theme";
|
|
import DashKpiCards from "../components/dashboard/DashKpiCards";
|
|
import DashQuickActions from "../components/dashboard/DashQuickActions";
|
|
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
|
|
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
|
|
import DashProfile from "../components/dashboard/DashProfile";
|
|
import DashSessions from "../components/dashboard/DashSessions";
|
|
import DashTodayPlan, {
|
|
type TodayPlan,
|
|
} from "../components/dashboard/DashTodayPlan";
|
|
|
|
const API_BASE = "/api/admin";
|
|
|
|
interface DashData {
|
|
my_shift?: { has_ongoing: boolean };
|
|
attendance?: {
|
|
present_today: number;
|
|
total_active: number;
|
|
on_leave: number;
|
|
users: Array<{
|
|
user_id: number | string;
|
|
name: string;
|
|
initials?: string;
|
|
status: string;
|
|
leave_type?: string;
|
|
arrived_at?: string;
|
|
}>;
|
|
};
|
|
offers?: {
|
|
open_count: number;
|
|
converted_count: number;
|
|
expired_count: number;
|
|
created_this_month: number;
|
|
};
|
|
projects?: {
|
|
active_projects: Array<{
|
|
id: number;
|
|
name: string;
|
|
customer_name: string | null;
|
|
}>;
|
|
};
|
|
invoices?: {
|
|
revenue_this_month: Array<{ amount: number; currency: string }>;
|
|
unpaid_count: number;
|
|
revenue_czk: number | null;
|
|
};
|
|
leave_pending?: { count: number };
|
|
recent_activity?: Array<{
|
|
id: number | string;
|
|
action: string;
|
|
entity_type: string;
|
|
description: string;
|
|
username?: string;
|
|
created_at: string;
|
|
}>;
|
|
users_count?: number;
|
|
active_projects?: number;
|
|
pending_orders?: number;
|
|
unpaid_invoices?: number;
|
|
pending_leave_requests?: number;
|
|
today_plan?: TodayPlan[];
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const { user, updateUser, hasPermission } = useAuth();
|
|
const alert = useAlert();
|
|
|
|
const [punching, setPunching] = useState(false);
|
|
|
|
const queryClient = useQueryClient();
|
|
const { data: dashDataRaw, isPending: dashLoading } =
|
|
useQuery(dashboardOptions());
|
|
const dashData = dashDataRaw as DashData | undefined;
|
|
// Personal 2FA enrollment (users.totp_enabled) — NOT the company-wide
|
|
// require_2fa policy flag (the banner below uses user.require2FA for that).
|
|
const { data: totpData, isPending: totpLoading } =
|
|
useQuery(totpStatusOptions());
|
|
const totpEnabled = totpData?.totp_enabled ?? !!user?.totpEnabled;
|
|
|
|
const punchMutation = useApiMutation<
|
|
Record<string, unknown>,
|
|
{ message?: string }
|
|
>({
|
|
url: () => `${API_BASE}/attendance`,
|
|
method: () => "POST",
|
|
invalidate: ["attendance", "dashboard"],
|
|
});
|
|
|
|
// 2FA state - sdileny mezi profilem a bannerem
|
|
const [show2FASetup, setShow2FASetup] = useState(false);
|
|
const [show2FADisable, setShow2FADisable] = useState(false);
|
|
const [totpSecret, setTotpSecret] = useState<string | null>(null);
|
|
const [totpQrUri, setTotpQrUri] = useState<string | null>(null);
|
|
const [totpCode, setTotpCode] = useState("");
|
|
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
|
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
|
const [disableCode, setDisableCode] = useState("");
|
|
|
|
// Punch (prichod/odchod) primo z dashboardu
|
|
const handleQuickPunch = useCallback(() => {
|
|
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
|
|
setPunching(true);
|
|
|
|
const submitPunch = async (gpsData: Record<string, unknown> = {}) => {
|
|
try {
|
|
const result = await punchMutation.mutateAsync({
|
|
punch_action: action,
|
|
...gpsData,
|
|
});
|
|
alert.success(result?.message || "Docházka zaznamenána");
|
|
} catch (e) {
|
|
alert.error(e instanceof Error ? e.message : "Chyba pripojeni");
|
|
} finally {
|
|
setPunching(false);
|
|
}
|
|
};
|
|
|
|
if (!navigator.geolocation) {
|
|
submitPunch({});
|
|
return;
|
|
}
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
const { latitude, longitude, accuracy } = pos.coords;
|
|
submitPunch({ latitude, longitude, accuracy, address: "" });
|
|
},
|
|
() => submitPunch({}),
|
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
|
|
);
|
|
}, [dashData, alert, punchMutation]);
|
|
|
|
// 2FA handlery
|
|
const handleStart2FASetup = async () => {
|
|
setTotpSubmitting(true);
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/totp/setup`);
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setTotpSecret(data.data.secret);
|
|
setTotpQrUri(data.data.uri || data.data.qr_uri);
|
|
setTotpCode("");
|
|
setBackupCodes(null);
|
|
setShow2FASetup(true);
|
|
} else {
|
|
alert.error(data.error || "Nepodařilo se vygenerovat 2FA klíč");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setTotpSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleConfirm2FA = async () => {
|
|
if (!totpCode.trim()) return;
|
|
setTotpSubmitting(true);
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/totp/enable`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ secret: totpSecret, code: totpCode.trim() }),
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
queryClient.invalidateQueries({ queryKey: ["totp"] });
|
|
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
setBackupCodes(data.data?.backup_codes || null);
|
|
setTotpSecret(null);
|
|
setTotpQrUri(null);
|
|
updateUser({ totpEnabled: true });
|
|
alert.success("2FA bylo aktivováno");
|
|
} else {
|
|
alert.error(data.error || "Neplatný kód");
|
|
setTotpCode("");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setTotpSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDisable2FA = async () => {
|
|
if (!disableCode.trim()) return;
|
|
setTotpSubmitting(true);
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/totp/disable`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code: disableCode.trim() }),
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
queryClient.invalidateQueries({ queryKey: ["totp"] });
|
|
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
setShow2FADisable(false);
|
|
setDisableCode("");
|
|
updateUser({ totpEnabled: false });
|
|
alert.success("2FA bylo deaktivováno");
|
|
} else {
|
|
alert.error(data.error || "Neplatný kód");
|
|
setDisableCode("");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setTotpSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PageEnter>
|
|
{/* Header */}
|
|
<Box sx={{ mb: 3 }}>
|
|
<Typography variant="h4">
|
|
Vítejte zpět, {user?.fullName || user?.username}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
{getCzechDate()}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* 2FA Required Banner */}
|
|
{user?.require2FA && !user?.totpEnabled && (
|
|
<Card
|
|
sx={{
|
|
mb: 3,
|
|
border: 2,
|
|
borderColor: "error.main",
|
|
backgroundColor:
|
|
"rgba(var(--mui-palette-error-mainChannel) / 0.12)",
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 2,
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
|
<Box
|
|
sx={[
|
|
{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: "50%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
},
|
|
iconBadgeSx("error"),
|
|
]}
|
|
>
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
<line x1="12" y1="9" x2="12" y2="13" />
|
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
</svg>
|
|
</Box>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 600 }}>
|
|
Dvoufaktorové ověření je povinné
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
|
|
nemáte přístup k ostatním sekcím systému.
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
<Button
|
|
onClick={handleStart2FASetup}
|
|
disabled={totpSubmitting}
|
|
sx={{ flexShrink: 0 }}
|
|
>
|
|
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
|
|
</Button>
|
|
</Box>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Loading spinner */}
|
|
{dashLoading && (
|
|
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
)}
|
|
|
|
{/* KPI cards — only show if user has any admin-level permissions */}
|
|
{!dashLoading &&
|
|
(hasPermission("offers.view") ||
|
|
hasPermission("invoices.view") ||
|
|
hasPermission("projects.view") ||
|
|
hasPermission("orders.view")) && (
|
|
<DashKpiCards dashData={dashData ?? null} />
|
|
)}
|
|
|
|
{/* My work plan for today — paired with the clock-in below */}
|
|
{!dashLoading &&
|
|
(hasPermission("attendance.record") ||
|
|
hasPermission("attendance.manage")) && (
|
|
<DashTodayPlan plans={dashData?.today_plan ?? []} />
|
|
)}
|
|
|
|
{/* Quick actions */}
|
|
{!dashLoading && (
|
|
<DashQuickActions
|
|
dashData={dashData ?? null}
|
|
punching={punching}
|
|
onPunch={handleQuickPunch}
|
|
/>
|
|
)}
|
|
|
|
{/* Main content grid */}
|
|
{!dashLoading && (
|
|
<Box
|
|
sx={{
|
|
display: "grid",
|
|
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
|
|
gap: 3,
|
|
alignItems: "start",
|
|
mb: 3,
|
|
}}
|
|
>
|
|
{hasPermission("settings.audit") && (
|
|
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
|
|
)}
|
|
|
|
{hasPermission("attendance.manage") && (
|
|
<DashAttendanceToday attendance={dashData?.attendance ?? null} />
|
|
)}
|
|
|
|
{/* Pravy sloupec: projekty + nabidky */}
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
{dashData?.projects && (
|
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Typography variant="h6">Aktivní projekty</Typography>
|
|
<Button
|
|
component={RouterLink}
|
|
to="/projects"
|
|
size="small"
|
|
sx={{ flexShrink: 0 }}
|
|
>
|
|
Vše →
|
|
</Button>
|
|
</Box>
|
|
{dashData.projects.active_projects.length === 0 && (
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{ py: 1 }}
|
|
>
|
|
Žádné aktivní projekty
|
|
</Typography>
|
|
)}
|
|
{dashData.projects.active_projects.map((p) => (
|
|
<Box
|
|
key={p.id}
|
|
component={RouterLink}
|
|
to={`/projects/${p.id}`}
|
|
sx={{
|
|
display: "block",
|
|
py: 1.25,
|
|
textDecoration: "none",
|
|
color: "inherit",
|
|
borderBottom: 1,
|
|
borderColor: "divider",
|
|
"&:last-of-type": { borderBottom: 0 },
|
|
"&:hover": { color: "primary.main" },
|
|
}}
|
|
>
|
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
|
{p.name}
|
|
</Typography>
|
|
{p.customer_name && (
|
|
<Typography variant="caption" color="text.secondary">
|
|
{p.customer_name}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
))}
|
|
</Card>
|
|
)}
|
|
|
|
{dashData?.offers && (
|
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Typography variant="h6">Nabídky</Typography>
|
|
<Button
|
|
component={RouterLink}
|
|
to="/offers"
|
|
size="small"
|
|
sx={{ flexShrink: 0 }}
|
|
>
|
|
Zobrazit →
|
|
</Button>
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
py: 1,
|
|
borderBottom: 1,
|
|
borderColor: "divider",
|
|
}}
|
|
>
|
|
<Typography variant="body2">Otevřené</Typography>
|
|
<StatusChip color="info" label={dashData.offers.open_count} />
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
py: 1,
|
|
borderBottom: 1,
|
|
borderColor: "divider",
|
|
}}
|
|
>
|
|
<Typography variant="body2">
|
|
Převedené na objednávku
|
|
</Typography>
|
|
<StatusChip
|
|
color="success"
|
|
label={dashData.offers.converted_count}
|
|
/>
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
py: 1,
|
|
}}
|
|
>
|
|
<Typography variant="body2">Zneplatněné</Typography>
|
|
<StatusChip
|
|
color="warning"
|
|
label={dashData.offers.expired_count}
|
|
/>
|
|
</Box>
|
|
</Card>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Profile + Sessions */}
|
|
{!dashLoading && (
|
|
<Box
|
|
sx={{
|
|
display: "grid",
|
|
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
|
|
gap: 3,
|
|
alignItems: "start",
|
|
}}
|
|
>
|
|
<DashProfile
|
|
totpEnabled={totpEnabled}
|
|
totpLoading={totpLoading}
|
|
totpSubmitting={totpSubmitting}
|
|
onStart2FASetup={handleStart2FASetup}
|
|
onConfirm2FA={handleConfirm2FA}
|
|
onDisable2FA={handleDisable2FA}
|
|
totpSecret={totpSecret}
|
|
totpQrUri={totpQrUri}
|
|
totpCode={totpCode}
|
|
setTotpCode={setTotpCode}
|
|
backupCodes={backupCodes}
|
|
setBackupCodes={setBackupCodes}
|
|
show2FASetup={show2FASetup}
|
|
setShow2FASetup={setShow2FASetup}
|
|
show2FADisable={show2FADisable}
|
|
setShow2FADisable={setShow2FADisable}
|
|
disableCode={disableCode}
|
|
setDisableCode={setDisableCode}
|
|
/>
|
|
<DashSessions />
|
|
</Box>
|
|
)}
|
|
</PageEnter>
|
|
);
|
|
}
|