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>
290 lines
9.5 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|