feat(mui): migrate Dashboard (Přehled) onto MUI kit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 01:19:49 +02:00
parent 5ca03bd662
commit 5459d8c325
7 changed files with 1205 additions and 968 deletions

View File

@@ -1,9 +1,17 @@
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 FormModal from "../FormModal";
import { Card, Button, Modal, Field, TextField } from "../../ui";
import useDialogScrollLock from "../../ui/useDialogScrollLock";
const API_BASE = "/api/admin";
@@ -37,6 +45,34 @@ interface ProfileFormData {
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,
@@ -61,6 +97,10 @@ export default function DashProfile({
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: "",
@@ -133,80 +173,94 @@ export default function DashProfile({
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
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Váš účet</h2>
<button
onClick={openEditModal}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
<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>
Upravit
</button>
</div>
<div className="admin-card-body">
<div className="dash-profile-grid">
<div className="dash-profile-item">
<span className="dash-profile-label">Uživatel</span>
<span className="dash-profile-value">{user?.username}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">E-mail</span>
<span className="dash-profile-value">{user?.email}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Jméno</span>
<span className="dash-profile-value">{user?.fullName}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Role</span>
<span className="dash-profile-value">
{user?.roleDisplay || String(user?.role || "")}
</span>
</div>
</div>
{/* 2FA Section */}
<div
style={{
borderTop: "1px solid var(--border-color)",
marginTop: "1rem",
paddingTop: "1rem",
<Card>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<div className="flex-between">
<div className="flex-row-gap">
<div
style={{
<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",
background: totpEnabled
? "var(--success-light)"
: "rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)",
color: totpEnabled
? "var(--success)"
: "var(--text-secondary)",
flexShrink: 0,
bgcolor: totpEnabled ? "success.light" : "action.hover",
color: totpEnabled ? "success.main" : "text.secondary",
}}
>
<svg
@@ -220,406 +274,390 @@ export default function DashProfile({
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div>
<div style={{ fontWeight: 500, fontSize: "0.875rem" }}>
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
Dvoufaktorové ověření (2FA)
</div>
<div
className={totpEnabled ? "text-success" : "text-secondary"}
style={{ fontSize: "0.75rem" }}
</Typography>
<Typography
variant="caption"
sx={{
color: totpEnabled ? "success.main" : "text.secondary",
}}
>
{getTotpStatusText()}
</div>
</div>
</div>
</Typography>
</Box>
</Box>
{!totpLoading &&
(totpEnabled ? (
<button
<Button
onClick={() => {
setDisableCode("");
setShow2FADisable(true);
}}
className="admin-btn admin-btn-primary admin-btn-sm"
size="small"
sx={{ flexShrink: 0 }}
>
Deaktivovat
</button>
</Button>
) : (
<button
<Button
onClick={onStart2FASetup}
disabled={totpSubmitting}
className="admin-btn admin-btn-primary admin-btn-sm"
size="small"
sx={{ flexShrink: 0 }}
>
{totpSubmitting ? "Generuji..." : "Aktivovat"}
</button>
</Button>
))}
</div>
</div>
</div>
</Box>
</Box>
</Card>
</motion.div>
{/* Edit Profile Modal */}
<FormModal
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="Upravit profil"
size="md"
maxWidth="sm"
onSubmit={handleSubmit}
submitLabel="Uložit změny"
submitText="Uložit změny"
>
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Jméno</label>
<input
type="text"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příjmení</label>
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Uživatelské jméno</label>
<input
type="text"
value={formData.username}
<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, username: e.target.value })
setFormData({ ...formData, first_name: e.target.value })
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">E-mail</label>
<input
type="email"
value={formData.email}
</Field>
<Field label="Příjmení">
<TextField
value={formData.last_name}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
setFormData({ ...formData, last_name: e.target.value })
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Aktuální heslo</label>
<input
type="password"
value={formData.current_password}
onChange={(e) =>
setFormData({
...formData,
current_password: e.target.value,
})
}
className="admin-form-input"
placeholder="Pro změnu hesla, zadejte aktuální heslo"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Nové heslo</label>
<input
type="password"
value={formData.new_password}
onChange={(e) =>
setFormData({
...formData,
new_password: e.target.value,
})
}
className="admin-form-input"
placeholder="Pro změnu hesla, zadejte nové heslo"
/>
</div>
</div>
</FormModal>
</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 Modal — multi-step: setup → backup codes */}
<FormModal
isOpen={show2FASetup}
onClose={() => {
// Only reachable on the setup step (backdrop/ESC/× are locked while
// backupCodes is set — that branch is unreachable so removed).
setShow2FASetup(false);
setTotpCode("");
}}
title={backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
size="md"
loading={backupCodes ? false : totpSubmitting}
onSubmit={
{/* 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);
setBackupCodes(null);
setTotpCode("");
}
: onConfirm2FA
}
submitLabel={
backupCodes ? "Rozumím, uložil jsem si kódy" : "Aktivovat 2FA"
}
submitDisabled={backupCodes ? false : totpCode.length !== 6}
cancelLabel="Zrušit"
// On the backup-codes step the cancel/× would silently dismiss unsaved
// codes, so hide the footer + × and lock backdrop/ESC; the explicit
// in-body primary is the single deliberate exit.
hideFooter={!!backupCodes}
hideCloseButton={!!backupCodes}
closeOnBackdrop={!backupCodes}
closeOnEsc={!backupCodes}
fullWidth
maxWidth="sm"
disableScrollLock
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
>
{backupCodes ? (
<div>
<div className="admin-role-locked-notice mb-4">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
<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: "warning.light",
color: "warning.main",
fontSize: "0.875rem",
}}
>
<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>
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.
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "0.5rem",
padding: "1rem",
background: "var(--bg-secondary)",
borderRadius: "0.5rem",
fontFamily: "monospace",
fontSize: "1rem",
}}
>
{backupCodes.map((code) => (
<div
key={code}
style={{
padding: "0.25rem 0.5rem",
textAlign: "center",
color: "var(--text-primary)",
<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}
>
{code}
</div>
))}
</div>
<div style={{ marginTop: "0.75rem" }}>
<button
onClick={() => {
navigator.clipboard?.writeText(backupCodes.join("\n"));
alert.success("Kódy zkopírovány");
}}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
<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>
Kopírovat kódy
</button>
</div>
{/* Footer is hidden on this step; the single deliberate exit lives
in the body and also clears the codes. */}
<div style={{ marginTop: "1.25rem", textAlign: "right" }}>
<button
onClick={() => {
setShow2FASetup(false);
setBackupCodes(null);
}}
className="admin-btn admin-btn-primary"
>
Rozumím, uložil jsem si kódy
</button>
</div>
</div>
) : (
<div>
<p
className="text-secondary"
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
>
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authy, Microsoft Authenticator apod.)
</p>
{totpQrUri && (
<div style={{ textAlign: "center", marginBottom: "1rem" }}>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
alt="TOTP QR Code"
style={{
width: 200,
height: 200,
borderRadius: "0.5rem",
border: "1px solid var(--border-color)",
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();
}
}}
/>
</div>
)}
{totpSecret && (
<div className="mb-4">
<label
className="admin-form-label"
style={{ fontSize: "0.75rem" }}
>
Nebo zadejte klíč ručně:
</label>
<div
style={{
padding: "0.5rem 0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.375rem",
fontFamily: "monospace",
fontSize: "0.875rem",
wordBreak: "break-all",
color: "var(--text-primary)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
}}
>
<span>{totpSecret}</span>
<button
onClick={() => {
navigator.clipboard?.writeText(totpSecret);
alert.success("Klíč zkopírován");
}}
className="admin-btn-icon"
title="Kopírovat"
aria-label="Kopírovat"
style={{ flexShrink: 0 }}
>
<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>
</button>
</div>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">
Ověřovací kód z aplikace
</label>
<input
ref={totpSetupRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="admin-form-input"
style={{
textAlign: "center",
fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "monospace",
</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("");
}}
onKeyDown={(e) => {
if (e.key === "Enter" && totpCode.length === 6) {
onConfirm2FA();
}
}}
/>
</div>
</div>
)}
</FormModal>
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 */}
<FormModal
<Modal
isOpen={show2FADisable}
onClose={() => setShow2FADisable(false)}
title="Deaktivovat 2FA"
size="md"
maxWidth="sm"
loading={totpSubmitting}
onSubmit={onDisable2FA}
submitDisabled={disableCode.length !== 6}
submitLabel="Deaktivovat 2FA"
cancelLabel="Zrušit"
submitText="Deaktivovat 2FA"
cancelText="Zrušit"
>
<p
style={{
color: "var(--text-secondary)",
fontSize: "0.875rem",
marginBottom: "1rem",
}}
>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
autentizační aplikace.
</p>
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód</label>
<input
type="text"
</Typography>
<Field label="Ověřovací kód">
<TextField
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="admin-form-input"
style={{
textAlign: "center",
fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "monospace",
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();
}
}}
autoFocus
/>
</div>
</FormModal>
</Field>
</Modal>
</>
);
}