Files
app/src/admin/components/dashboard/DashSessions.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

290 lines
9.5 KiB
TypeScript

import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion, useReducedMotion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import { useAlert } from "../../context/AlertContext";
import { Card, Button, StatusChip, ConfirmDialog } from "../../ui";
import apiFetch from "../../utils/api";
import { formatSessionDate } from "../../utils/dashboardHelpers";
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
const API_BASE = "/api/admin";
interface DeleteModalState {
isOpen: boolean;
session: Session | null;
}
function getDeviceIcon(iconType?: string) {
switch (iconType) {
case "smartphone":
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12" y2="18" />
</svg>
);
case "tablet":
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12" y2="18" />
</svg>
);
default:
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
);
}
}
export default function DashSessions() {
const alert = useAlert();
const queryClient = useQueryClient();
const reduce = useReducedMotion();
const { data: sessions = [], isPending: sessionsLoading } =
useQuery(sessionsOptions());
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
isOpen: false,
session: null,
});
const [deleteAllModal, setDeleteAllModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDeleteSession = async () => {
if (!deleteModal.session) {
return;
}
const sessionId = deleteModal.session.id;
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/sessions/${sessionId}`, {
method: "DELETE",
});
const data = await response.json();
if (data.success) {
setDeleteModal({ isOpen: false, session: null });
queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success("Relace byla ukončena");
} else {
alert.error(data.error || "Nepodařilo se ukončit relaci");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
}
};
const handleDeleteAllSessions = async () => {
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/sessions?action=all`, {
method: "DELETE",
});
const data = await response.json();
if (data.success) {
setDeleteAllModal(false);
queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success(data.message || "Ostatní relace byly ukončeny");
} else {
alert.error(data.error || "Nepodařilo se ukončit relace");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
}
};
return (
<>
<motion.div
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" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1.5,
mb: 2,
}}
>
<Typography variant="h6">Přihlášená zařízení</Typography>
{sessions.filter((s) => !s.is_current).length > 0 && (
<Button
onClick={() => setDeleteAllModal(true)}
variant="outlined"
color="inherit"
size="small"
sx={{ flexShrink: 0 }}
>
Odhlásit ostatní
</Button>
)}
</Box>
{sessionsLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress size={28} />
</Box>
) : sessions.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center", py: 3 }}
>
Žádné aktivní relace
</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{sessions.map((session) => (
<Box
key={session.id}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
"&:last-of-type": { borderBottom: 0 },
...(session.is_current
? { bgcolor: "action.hover", borderRadius: 2, px: 1 }
: {}),
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "text.secondary",
flexShrink: 0,
}}
>
{getDeviceIcon(session.device_info?.icon)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{session.device_info?.browser} na{" "}
{session.device_info?.os}
</Typography>
{session.is_current && (
<StatusChip color="success" label="Aktuální" />
)}
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
color: "text.secondary",
fontSize: "0.75rem",
}}
>
<span>{session.ip_address}</span>
<span>|</span>
<span>{formatSessionDate(session.created_at)}</span>
</Box>
</Box>
<Box sx={{ flexShrink: 0 }}>
{!session.is_current && (
<IconButton
onClick={() =>
setDeleteModal({ isOpen: true, session })
}
color="error"
size="small"
title="Ukončit relaci"
aria-label="Ukončit relaci"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</IconButton>
)}
</Box>
</Box>
))}
</Box>
)}
</Card>
</motion.div>
<ConfirmDialog
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, session: null })}
onConfirm={handleDeleteSession}
title="Ukončit relaci"
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
confirmText="Ukončit"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleting}
/>
<ConfirmDialog
isOpen={deleteAllModal}
onClose={() => setDeleteAllModal(false)}
onConfirm={handleDeleteAllSessions}
title="Odhlásit ostatní zařízení"
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
confirmText="Odhlásit vše"
cancelText="Zrušit"
confirmVariant="primary"
loading={deleting}
/>
</>
);
}