The labelled icon-badge tiles were inconsistent: some used a same-hue light tile + colored glyph (e.g. the Settings 2FA row: success.light tile + success.main glyph — low contrast, the reported "badly visible" icon), and the solid-tile ones used the bright .main fill in dark mode without darkening, so a white glyph on bright green/amber was low-contrast too (DashProfile's success badge was ~1.9:1). Add a single `iconBadgeSx(color)` helper (theme.ts): solid `<color>.main` fill + white glyph, darkened in dark mode (FILLED_DARK_BG) so white stays legible; `default` = neutral grey. Applied to every badge so they can't drift: StatCard, DashActivityFeed, DashProfile (2FA badge), Dashboard (2FA banner icon), AttendanceHistory (month tile), and the Settings 2FA row tile (now solid green when required / grey when optional + white lock, instead of the low-contrast light-green tile). Also switched DashProfile's backup-codes warning callout from warning.light to a subtle warning wash so its amber text stays readable in dark mode. tsc -b --noEmit, npm run build, vitest 152/152 all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
667 lines
21 KiB
TypeScript
667 lines
21 KiB
TypeScript
import { useState, useRef } from "react";
|
|
import { motion } from "framer-motion";
|
|
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";
|
|
|
|
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 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);
|
|
|
|
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();
|
|
const dataToSave = { ...formData };
|
|
|
|
if (dataToSave.new_password && !dataToSave.current_password) {
|
|
alert.error("Pro změnu hesla zadejte aktuální heslo");
|
|
return;
|
|
}
|
|
if (dataToSave.current_password && !dataToSave.new_password) {
|
|
alert.error("Pro změnu hesla zadejte nové heslo");
|
|
return;
|
|
}
|
|
|
|
// Strip empty password fields so Zod doesn't reject ""
|
|
if (!dataToSave.current_password)
|
|
delete (dataToSave as any).current_password;
|
|
if (!dataToSave.new_password) delete (dataToSave as any).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(),
|
|
});
|
|
setShowModal(false);
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
alert.success("Profil byl upraven");
|
|
} else {
|
|
alert.error(data.error || "Nepodařilo se uložit profil");
|
|
}
|
|
} catch {
|
|
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={{ opacity: 0, y: 12 }}
|
|
animate={{ 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={() => {
|
|
navigator.clipboard?.writeText(backupCodes.join("\n"));
|
|
alert.success("Kódy zkopírovány");
|
|
}}
|
|
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 && (
|
|
<Box sx={{ textAlign: "center", mb: 2 }}>
|
|
<Box
|
|
component="img"
|
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
|
|
alt="TOTP QR Code"
|
|
sx={{
|
|
width: 200,
|
|
height: 200,
|
|
borderRadius: 2,
|
|
border: 1,
|
|
borderColor: "divider",
|
|
}}
|
|
/>
|
|
</Box>
|
|
)}
|
|
{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={() => {
|
|
navigator.clipboard?.writeText(totpSecret);
|
|
alert.success("Klíč zkopírová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>
|
|
</>
|
|
);
|
|
}
|