Files
app/src/admin/components/dashboard/DashProfile.tsx
BOHA 54f3c414f5 fix(ui): unify icon badges — solid tile + white glyph via shared helper
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>
2026-06-07 21:34:06 +02:00

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>
</>
);
}