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 { 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 = ( ); const CopyIcon = ( ); 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(null); // The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and // locks 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({ 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 > & Omit = { 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 ( <> Váš účet {profileItems.map((item) => ( {item.label} {item.value} ))} {/* 2FA Section */} Dvoufaktorové ověření (2FA) {getTotpStatusText()} {!totpLoading && (totpEnabled ? ( ) : ( ))} {/* Edit Profile Modal */} setShowModal(false)} title="Upravit profil" maxWidth="sm" onSubmit={handleSubmit} submitText="Uložit změny" > setFormData({ ...formData, first_name: e.target.value }) } required /> setFormData({ ...formData, last_name: e.target.value }) } required /> setFormData({ ...formData, username: e.target.value }) } required /> setFormData({ ...formData, email: e.target.value }) } required /> setFormData({ ...formData, current_password: e.target.value }) } placeholder="Pro změnu hesla, zadejte aktuální heslo" /> setFormData({ ...formData, new_password: e.target.value }) } placeholder="Pro změnu hesla, zadejte nové heslo" /> {/* 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). */} { setShow2FASetup(false); setTotpCode(""); } } fullWidth maxWidth="sm" disableScrollLock slotProps={{ paper: { sx: { borderRadius: 3 } } }} > {backupCodes ? "Záložní kódy" : "Nastavení 2FA"} {backupCodes ? ( 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. {backupCodes.map((code) => ( {code} ))} ) : ( Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Authy, Microsoft Authenticator apod.) {totpQrUri && totpQrDataUrl && ( )} {totpQrUri && totpQrFailed && ( QR kód se nepodařilo vygenerovat. Zadejte prosím klíč do aplikace ručně. )} {totpSecret && ( Nebo zadejte klíč ručně: {totpSecret} { 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} )} 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(); } }} /> )} {/* Footer is hidden on the backup-codes step; the single deliberate exit lives in the body actions there. */} {backupCodes ? ( ) : ( <> )} {/* 2FA Disable Modal */} setShow2FADisable(false)} title="Deaktivovat 2FA" maxWidth="sm" loading={totpSubmitting} onSubmit={onDisable2FA} submitDisabled={disableCode.length !== 6} submitText="Deaktivovat 2FA" cancelText="Zrušit" > Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z autentizační aplikace. 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(); } }} /> ); }