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>
755 lines
24 KiB
TypeScript
755 lines
24 KiB
TypeScript
import { useState, useRef } from "react";
|
||
import { motion, useReducedMotion } from "framer-motion";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import QRCode from "qrcode";
|
||
import Box from "@mui/material/Box";
|
||
import Typography from "@mui/material/Typography";
|
||
import IconButton from "@mui/material/IconButton";
|
||
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 { useAuth } from "../../context/AuthContext";
|
||
import { useAlert } from "../../context/AlertContext";
|
||
import apiFetch from "../../utils/api";
|
||
import { iconBadgeSx } from "../../theme";
|
||
import { Card, Button, Modal, Field, TextField } from "../../ui";
|
||
import useDialogScrollLock from "../../ui/useDialogScrollLock";
|
||
|
||
const API_BASE = "/api/admin";
|
||
|
||
// Copy helper. navigator.clipboard is SECURE-CONTEXT ONLY (undefined over plain
|
||
// HTTP on a LAN), so we fall back to the legacy execCommand path — which works
|
||
// in insecure contexts — and report whether the copy actually succeeded. The
|
||
// caller MUST gate its success toast on the returned boolean (otherwise it lies
|
||
// to the user about copying 2FA codes / the TOTP secret).
|
||
async function copyToClipboard(text: string): Promise<boolean> {
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
}
|
||
} catch {
|
||
// fall through to the legacy path
|
||
}
|
||
try {
|
||
const ta = document.createElement("textarea");
|
||
ta.value = text;
|
||
ta.style.position = "fixed";
|
||
ta.style.opacity = "0";
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
const ok = document.execCommand("copy");
|
||
document.body.removeChild(ta);
|
||
return ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
interface DashProfileProps {
|
||
totpEnabled: boolean;
|
||
totpLoading: boolean;
|
||
totpSubmitting: boolean;
|
||
onStart2FASetup: () => void;
|
||
onConfirm2FA: () => void;
|
||
onDisable2FA: () => void;
|
||
totpSecret: string | null;
|
||
totpQrUri: string | null;
|
||
totpCode: string;
|
||
setTotpCode: (code: string) => void;
|
||
backupCodes: string[] | null;
|
||
setBackupCodes: (codes: string[] | null) => void;
|
||
show2FASetup: boolean;
|
||
setShow2FASetup: (show: boolean) => void;
|
||
show2FADisable: boolean;
|
||
setShow2FADisable: (show: boolean) => void;
|
||
disableCode: string;
|
||
setDisableCode: (code: string) => void;
|
||
}
|
||
|
||
interface ProfileFormData {
|
||
username: string;
|
||
email: string;
|
||
new_password: string;
|
||
current_password: string;
|
||
first_name: string;
|
||
last_name: string;
|
||
}
|
||
|
||
const EditIcon = (
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
>
|
||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||
</svg>
|
||
);
|
||
|
||
const CopyIcon = (
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
>
|
||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||
</svg>
|
||
);
|
||
|
||
export default function DashProfile({
|
||
totpEnabled,
|
||
totpLoading,
|
||
totpSubmitting,
|
||
onStart2FASetup,
|
||
onConfirm2FA,
|
||
onDisable2FA,
|
||
totpSecret,
|
||
totpQrUri,
|
||
totpCode,
|
||
setTotpCode,
|
||
backupCodes,
|
||
setBackupCodes,
|
||
show2FASetup,
|
||
setShow2FASetup,
|
||
show2FADisable,
|
||
setShow2FADisable,
|
||
disableCode,
|
||
setDisableCode,
|
||
}: 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
|
||
// locks <html> scroll like the kit dialogs.
|
||
useDialogScrollLock(show2FASetup);
|
||
|
||
// Generate the enrollment QR LOCALLY from the otpauth URI. Never send the
|
||
// URI to an external QR service: the production CSP (img-src) blocks it and
|
||
// it would leak the TOTP secret to a third party. A data: URL is allowed by
|
||
// the CSP. Derived via useQuery (not an effect) per repo conventions.
|
||
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
|
||
queryKey: ["totp", "qr", totpQrUri],
|
||
enabled: !!totpQrUri,
|
||
staleTime: Infinity,
|
||
// The URI embeds the TOTP secret — drop it from the cache as soon as the
|
||
// enrollment UI unmounts instead of keeping it for the default 5-min GC.
|
||
gcTime: 0,
|
||
retry: false,
|
||
queryFn: async () => {
|
||
try {
|
||
return await QRCode.toDataURL(totpQrUri!, { width: 200, margin: 2 });
|
||
} catch (err) {
|
||
console.error("DashProfile: generování QR kódu selhalo", err);
|
||
throw err;
|
||
}
|
||
},
|
||
});
|
||
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [formData, setFormData] = useState<ProfileFormData>({
|
||
username: "",
|
||
email: "",
|
||
new_password: "",
|
||
current_password: "",
|
||
first_name: "",
|
||
last_name: "",
|
||
});
|
||
|
||
const openEditModal = () => {
|
||
const nameParts = (user?.fullName || "").split(" ");
|
||
setFormData({
|
||
username: user?.username || "",
|
||
email: user?.email || "",
|
||
new_password: "",
|
||
current_password: "",
|
||
first_name: nameParts[0] || "",
|
||
last_name: nameParts.slice(1).join(" ") || "",
|
||
});
|
||
setShowModal(true);
|
||
};
|
||
|
||
const handleSubmit = async (e?: React.FormEvent) => {
|
||
e?.preventDefault();
|
||
|
||
if (formData.new_password && !formData.current_password) {
|
||
alert.error("Pro změnu hesla zadejte aktuální heslo");
|
||
return;
|
||
}
|
||
if (formData.current_password && !formData.new_password) {
|
||
alert.error("Pro změnu hesla zadejte nové heslo");
|
||
return;
|
||
}
|
||
|
||
// 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`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(dataToSave),
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
updateUser({
|
||
username: dataToSave.username,
|
||
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 (err) {
|
||
console.error("DashProfile: uložení profilu selhalo", err);
|
||
alert.error("Chyba připojení");
|
||
}
|
||
};
|
||
|
||
function getTotpStatusText(): string {
|
||
if (totpLoading) {
|
||
return "Načítání...";
|
||
}
|
||
return totpEnabled ? "Aktivní" : "Neaktivní";
|
||
}
|
||
|
||
const profileItems: Array<{ label: string; value: string }> = [
|
||
{ label: "Uživatel", value: user?.username || "" },
|
||
{ label: "E-mail", value: user?.email || "" },
|
||
{ label: "Jméno", value: user?.fullName || "" },
|
||
{
|
||
label: "Role",
|
||
value: user?.roleDisplay || String(user?.role || ""),
|
||
},
|
||
];
|
||
|
||
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>
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
mb: 2,
|
||
}}
|
||
>
|
||
<Typography variant="h6">Váš účet</Typography>
|
||
<Button
|
||
onClick={openEditModal}
|
||
variant="outlined"
|
||
color="inherit"
|
||
size="small"
|
||
startIcon={EditIcon}
|
||
>
|
||
Upravit
|
||
</Button>
|
||
</Box>
|
||
|
||
<Box
|
||
sx={{
|
||
display: "grid",
|
||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||
gap: 1.5,
|
||
}}
|
||
>
|
||
{profileItems.map((item) => (
|
||
<Box key={item.label}>
|
||
<Typography
|
||
variant="caption"
|
||
color="text.secondary"
|
||
sx={{
|
||
display: "block",
|
||
textTransform: "uppercase",
|
||
letterSpacing: ".05em",
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{item.label}
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||
{item.value}
|
||
</Typography>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
|
||
{/* 2FA Section */}
|
||
<Box sx={{ borderTop: 1, borderColor: "divider", mt: 2, pt: 2 }}>
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 1.5,
|
||
}}
|
||
>
|
||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||
<Box
|
||
sx={[
|
||
{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: "50%",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexShrink: 0,
|
||
},
|
||
iconBadgeSx(totpEnabled ? "success" : "default"),
|
||
]}
|
||
>
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
>
|
||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||
</svg>
|
||
</Box>
|
||
<Box>
|
||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||
Dvoufaktorové ověření (2FA)
|
||
</Typography>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
color: totpEnabled ? "success.main" : "text.secondary",
|
||
}}
|
||
>
|
||
{getTotpStatusText()}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
{!totpLoading &&
|
||
(totpEnabled ? (
|
||
<Button
|
||
onClick={() => {
|
||
setDisableCode("");
|
||
setShow2FADisable(true);
|
||
}}
|
||
size="small"
|
||
sx={{ flexShrink: 0 }}
|
||
>
|
||
Deaktivovat
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
onClick={onStart2FASetup}
|
||
disabled={totpSubmitting}
|
||
size="small"
|
||
sx={{ flexShrink: 0 }}
|
||
>
|
||
{totpSubmitting ? "Generuji..." : "Aktivovat"}
|
||
</Button>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
{/* Edit Profile Modal */}
|
||
<Modal
|
||
isOpen={showModal}
|
||
onClose={() => setShowModal(false)}
|
||
title="Upravit profil"
|
||
maxWidth="sm"
|
||
onSubmit={handleSubmit}
|
||
submitText="Uložit změny"
|
||
>
|
||
<Box
|
||
sx={{
|
||
display: "grid",
|
||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||
gap: 2,
|
||
}}
|
||
>
|
||
<Field label="Jméno">
|
||
<TextField
|
||
value={formData.first_name}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, first_name: e.target.value })
|
||
}
|
||
required
|
||
/>
|
||
</Field>
|
||
<Field label="Příjmení">
|
||
<TextField
|
||
value={formData.last_name}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, last_name: e.target.value })
|
||
}
|
||
required
|
||
/>
|
||
</Field>
|
||
</Box>
|
||
<Field label="Uživatelské jméno">
|
||
<TextField
|
||
value={formData.username}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, username: e.target.value })
|
||
}
|
||
required
|
||
/>
|
||
</Field>
|
||
<Field label="E-mail">
|
||
<TextField
|
||
type="email"
|
||
value={formData.email}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, email: e.target.value })
|
||
}
|
||
required
|
||
/>
|
||
</Field>
|
||
<Field label="Aktuální heslo">
|
||
<TextField
|
||
type="password"
|
||
value={formData.current_password}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, current_password: e.target.value })
|
||
}
|
||
placeholder="Pro změnu hesla, zadejte aktuální heslo"
|
||
/>
|
||
</Field>
|
||
<Field label="Nové heslo">
|
||
<TextField
|
||
type="password"
|
||
value={formData.new_password}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, new_password: e.target.value })
|
||
}
|
||
placeholder="Pro změnu hesla, zadejte nové heslo"
|
||
/>
|
||
</Field>
|
||
</Modal>
|
||
|
||
{/* 2FA Setup Dialog — bespoke, multi-step: setup → backup codes.
|
||
On the backup-codes step the footer + close are hidden and
|
||
backdrop/ESC are locked; the single deliberate exit lives in the body
|
||
and also clears the codes (so unsaved codes can't be silently lost). */}
|
||
<Dialog
|
||
open={show2FASetup}
|
||
onClose={
|
||
backupCodes
|
||
? undefined
|
||
: () => {
|
||
setShow2FASetup(false);
|
||
setTotpCode("");
|
||
}
|
||
}
|
||
fullWidth
|
||
maxWidth="sm"
|
||
disableScrollLock
|
||
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||
>
|
||
<DialogTitle>
|
||
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
|
||
</DialogTitle>
|
||
<DialogContent>
|
||
{backupCodes ? (
|
||
<Box>
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 1,
|
||
p: 1.5,
|
||
mb: 2,
|
||
borderRadius: 2,
|
||
bgcolor:
|
||
"rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
|
||
color: "warning.main",
|
||
fontSize: "0.875rem",
|
||
}}
|
||
>
|
||
<Box sx={{ flexShrink: 0, mt: "2px" }}>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
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>
|
||
<span>
|
||
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít
|
||
pouze jednou. Po zavření tohoto okna je již neuvidíte.
|
||
</span>
|
||
</Box>
|
||
<Box
|
||
sx={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(2, 1fr)",
|
||
gap: 1,
|
||
p: 2,
|
||
bgcolor: "action.hover",
|
||
borderRadius: 2,
|
||
fontFamily: "'DM Mono', Menlo, monospace",
|
||
fontSize: "1rem",
|
||
}}
|
||
>
|
||
{backupCodes.map((code) => (
|
||
<Box
|
||
key={code}
|
||
sx={{
|
||
px: 1,
|
||
py: 0.5,
|
||
textAlign: "center",
|
||
color: "text.primary",
|
||
}}
|
||
>
|
||
{code}
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
<Box sx={{ mt: 1.5 }}>
|
||
<Button
|
||
onClick={async () => {
|
||
const ok = await copyToClipboard(backupCodes.join("\n"));
|
||
if (ok) alert.success("Kódy zkopírovány");
|
||
else alert.error("Kopírování selhalo – zkopírujte ručně");
|
||
}}
|
||
variant="outlined"
|
||
color="inherit"
|
||
size="small"
|
||
startIcon={CopyIcon}
|
||
>
|
||
Kopírovat kódy
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
) : (
|
||
<Box>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
|
||
Authy, Microsoft Authenticator apod.)
|
||
</Typography>
|
||
{totpQrUri && totpQrDataUrl && (
|
||
<Box sx={{ textAlign: "center", mb: 2 }}>
|
||
<Box
|
||
component="img"
|
||
src={totpQrDataUrl}
|
||
alt="TOTP QR Code"
|
||
sx={{
|
||
width: 200,
|
||
height: 200,
|
||
borderRadius: 2,
|
||
border: 1,
|
||
borderColor: "divider",
|
||
// The QR must stay scannable in dark mode — keep it on a
|
||
// white tile instead of inheriting the dark paper bg.
|
||
bgcolor: "common.white",
|
||
}}
|
||
/>
|
||
</Box>
|
||
)}
|
||
{totpQrUri && totpQrFailed && (
|
||
<Typography
|
||
variant="body2"
|
||
color="warning.main"
|
||
sx={{ mb: 2, textAlign: "center" }}
|
||
>
|
||
QR kód se nepodařilo vygenerovat. Zadejte prosím klíč do
|
||
aplikace ručně.
|
||
</Typography>
|
||
)}
|
||
{totpSecret && (
|
||
<Box sx={{ mb: 2 }}>
|
||
<Typography
|
||
component="label"
|
||
variant="caption"
|
||
color="text.secondary"
|
||
sx={{ display: "block", mb: 0.5 }}
|
||
>
|
||
Nebo zadejte klíč ručně:
|
||
</Typography>
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 1,
|
||
px: 1.5,
|
||
py: 1,
|
||
bgcolor: "action.hover",
|
||
borderRadius: 1.5,
|
||
fontFamily: "'DM Mono', Menlo, monospace",
|
||
fontSize: "0.875rem",
|
||
wordBreak: "break-all",
|
||
color: "text.primary",
|
||
}}
|
||
>
|
||
<span>{totpSecret}</span>
|
||
<IconButton
|
||
onClick={async () => {
|
||
const ok = await copyToClipboard(totpSecret);
|
||
if (ok) alert.success("Klíč zkopírován");
|
||
else
|
||
alert.error("Kopírování selhalo – zkopírujte ručně");
|
||
}}
|
||
size="small"
|
||
title="Kopírovat"
|
||
aria-label="Kopírovat"
|
||
sx={{ flexShrink: 0 }}
|
||
>
|
||
{CopyIcon}
|
||
</IconButton>
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
<Field label="Ověřovací kód z aplikace">
|
||
<TextField
|
||
inputRef={totpSetupRef}
|
||
inputMode="numeric"
|
||
value={totpCode}
|
||
onChange={(e) =>
|
||
setTotpCode(e.target.value.replace(/\D/g, ""))
|
||
}
|
||
placeholder="000000"
|
||
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
|
||
sx={{
|
||
"& input": {
|
||
textAlign: "center",
|
||
fontSize: "1.25rem",
|
||
letterSpacing: "0.4rem",
|
||
fontFamily: "'DM Mono', Menlo, monospace",
|
||
},
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && totpCode.length === 6) {
|
||
onConfirm2FA();
|
||
}
|
||
}}
|
||
/>
|
||
</Field>
|
||
</Box>
|
||
)}
|
||
</DialogContent>
|
||
{/* Footer is hidden on the backup-codes step; the single deliberate exit
|
||
lives in the body actions there. */}
|
||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||
{backupCodes ? (
|
||
<Button
|
||
onClick={() => {
|
||
setShow2FASetup(false);
|
||
setBackupCodes(null);
|
||
}}
|
||
>
|
||
Rozumím, uložil jsem si kódy
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<Button
|
||
onClick={() => {
|
||
setShow2FASetup(false);
|
||
setTotpCode("");
|
||
}}
|
||
variant="text"
|
||
color="inherit"
|
||
disabled={totpSubmitting}
|
||
>
|
||
Zrušit
|
||
</Button>
|
||
<Button
|
||
onClick={onConfirm2FA}
|
||
disabled={totpSubmitting || totpCode.length !== 6}
|
||
>
|
||
{totpSubmitting ? "Ukládám…" : "Aktivovat 2FA"}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
{/* 2FA Disable Modal */}
|
||
<Modal
|
||
isOpen={show2FADisable}
|
||
onClose={() => setShow2FADisable(false)}
|
||
title="Deaktivovat 2FA"
|
||
maxWidth="sm"
|
||
loading={totpSubmitting}
|
||
onSubmit={onDisable2FA}
|
||
submitDisabled={disableCode.length !== 6}
|
||
submitText="Deaktivovat 2FA"
|
||
cancelText="Zrušit"
|
||
>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
|
||
autentizační aplikace.
|
||
</Typography>
|
||
<Field label="Ověřovací kód">
|
||
<TextField
|
||
inputMode="numeric"
|
||
value={disableCode}
|
||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))}
|
||
placeholder="000000"
|
||
autoFocus
|
||
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
|
||
sx={{
|
||
"& input": {
|
||
textAlign: "center",
|
||
fontSize: "1.25rem",
|
||
letterSpacing: "0.4rem",
|
||
fontFamily: "'DM Mono', Menlo, monospace",
|
||
},
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && disableCode.length === 6) {
|
||
onDisable2FA();
|
||
}
|
||
}}
|
||
/>
|
||
</Field>
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|