feat(mui): migrate Login (auth shell + 2FA) onto MUI kit; drop login.css

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 01:24:11 +02:00
parent 5459d8c325
commit cc4b14f862
3 changed files with 254 additions and 295 deletions

View File

@@ -20,7 +20,6 @@ import "./datepicker.css";
import "./filemanager.css"; import "./filemanager.css";
import "./pagination.css"; import "./pagination.css";
import "./responsive.css"; import "./responsive.css";
import "./login.css";
import "./dashboard.css"; import "./dashboard.css";
import "./attendance.css"; import "./attendance.css";
import "./plan.css"; import "./plan.css";

View File

@@ -1,143 +0,0 @@
/* ============================================================================
Login Page
============================================================================ */
.admin-login {
min-height: 100vh;
min-height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--bg-primary);
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.admin-login .bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
}
.admin-login .bg-orb-1 {
width: 400px;
height: 400px;
background: var(--orb-color-1);
top: 10%;
left: 20%;
animation: float 20s ease-in-out infinite;
}
.admin-login .bg-orb-2 {
width: 320px;
height: 320px;
background: var(--orb-color-2);
bottom: 20%;
right: 15%;
animation: float 25s ease-in-out infinite reverse;
}
.admin-login-theme-btn {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: var(--glass-bg);
backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
cursor: pointer;
border-radius: 50%;
overflow: hidden;
transition: var(--transition);
z-index: 10;
}
.admin-login-theme-btn:hover {
background: var(--glass-bg-solid);
color: var(--text-primary);
border-color: var(--border-color-hover);
transform: scale(1.05);
}
.admin-login-card {
width: 100%;
max-width: 420px;
max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem);
padding: 2rem;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--border-radius-lg);
box-shadow: var(--glass-shadow);
position: relative;
z-index: 1;
overflow-y: auto;
}
@media (min-width: 640px) {
.admin-login-card {
padding: 2.5rem;
}
}
.admin-login-header {
text-align: center;
margin-bottom: 2rem;
}
.admin-login-logo {
height: 48px;
width: auto;
margin-bottom: 1rem;
}
.admin-login-2fa-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent-color);
margin-bottom: 1rem;
}
.admin-login-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
margin-bottom: 0.5rem;
}
.admin-login-subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
}
.admin-back-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: var(--transition);
margin-top: 1.5rem;
display: inline-block;
}
.admin-back-link:hover {
color: var(--text-primary);
text-decoration: underline;
}

View File

@@ -1,6 +1,10 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useTheme } from "../../context/ThemeContext"; import { useTheme } from "../../context/ThemeContext";
@@ -8,7 +12,7 @@ import {
shouldShowSessionExpiredAlert, shouldShowSessionExpiredAlert,
shouldShowLogoutAlert, shouldShowLogoutAlert,
} from "../utils/api"; } from "../utils/api";
import FormField from "../components/FormField"; import { TextField, Button, CheckboxField, Field } from "../ui";
export default function Login() { export default function Login() {
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth(); const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
@@ -132,11 +136,17 @@ export default function Login() {
if (authLoading) { if (authLoading) {
return ( return (
<div className="admin-login"> <Box
<div className="admin-loading"> sx={{
<div className="admin-spinner" /> minHeight: ["100vh", "100dvh"],
</div> display: "flex",
</div> alignItems: "center",
justifyContent: "center",
bgcolor: "var(--bg-primary)",
}}
>
<CircularProgress />
</Box>
); );
} }
@@ -144,32 +154,115 @@ export default function Login() {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
// Shared glass card sx (replaces .admin-login-card)
const cardSx = {
width: "100%",
maxWidth: 420,
maxHeight: "calc(100dvh - 2rem)",
p: { xs: 4, sm: 5 },
background: "var(--glass-bg)",
backdropFilter: "blur(20px)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--border-radius-lg)",
boxShadow: "var(--glass-shadow)",
position: "relative" as const,
zIndex: 1,
overflowY: "auto" as const,
};
// Framer entrance + shake choreography for the card panels (unchanged)
const cardMotion = {
initial: { opacity: 0, y: 30 },
animate: shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: shake
? { x: { duration: 0.5, ease: "easeOut" as const } }
: { duration: 0.3 },
};
return ( return (
<motion.div <Box
className="admin-login" component={motion.div}
initial={{ opacity: 0, scale: 0.98 }} initial={{ opacity: 0, scale: 0.98 }}
animate={ animate={
animatingOut animatingOut
? { scale: 1.5, opacity: 0, filter: "blur(12px)" } ? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
: { scale: 1, opacity: 1, filter: "none" } : { scale: 1, opacity: 1, filter: "none" }
} }
transition={ transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
animatingOut sx={{
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] } minHeight: ["100vh", "100dvh"],
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] } maxHeight: ["100vh", "100dvh"],
} display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
background: "var(--bg-primary)",
position: "relative",
overflow: "hidden",
boxSizing: "border-box",
}}
> >
<div className="bg-orb bg-orb-1" /> {/* Animated background orbs (replaces .bg-orb) */}
<div className="bg-orb bg-orb-2" /> <Box
aria-hidden
sx={{
position: "absolute",
borderRadius: "50%",
filter: "blur(80px)",
pointerEvents: "none",
width: 400,
height: 400,
background: "var(--orb-color-1)",
top: "10%",
left: "20%",
animation: "float 20s ease-in-out infinite",
}}
/>
<Box
aria-hidden
sx={{
position: "absolute",
borderRadius: "50%",
filter: "blur(80px)",
pointerEvents: "none",
width: 320,
height: 320,
background: "var(--orb-color-2)",
bottom: "20%",
right: "15%",
animation: "float 25s ease-in-out infinite reverse",
}}
/>
<button {/* Theme toggle (replaces .admin-login-theme-btn) */}
<IconButton
onClick={toggleTheme} onClick={toggleTheme}
className="admin-login-theme-btn"
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"} title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
sx={{
position: "absolute",
top: "1rem",
right: "1rem",
width: 44,
height: 44,
background: "var(--glass-bg)",
backdropFilter: "blur(10px)",
border: "1px solid var(--glass-border)",
color: "var(--text-secondary)",
overflow: "hidden",
zIndex: 10,
transition: "var(--transition)",
"&:hover": {
background: "var(--glass-bg-solid)",
color: "var(--text-primary)",
borderColor: "var(--border-color-hover)",
transform: "scale(1.05)",
},
}}
> >
<span {theme === "light" ? (
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
>
<svg <svg
width="20" width="20"
height="20" height="20"
@@ -181,10 +274,7 @@ export default function Login() {
<circle cx="12" cy="12" r="5" /> <circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /> <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg> </svg>
</span> ) : (
<span
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
>
<svg <svg
width="20" width="20"
height="20" height="20"
@@ -195,122 +285,114 @@ export default function Login() {
> >
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg> </svg>
</span> )}
</button> </IconButton>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{!show2FA ? ( {!show2FA ? (
<motion.div <Box key="login" component={motion.div} {...cardMotion} sx={cardSx}>
key="login" <Box sx={{ textAlign: "center", mb: 4 }}>
className="admin-login-card" <Box
initial={{ opacity: 0, y: 30 }} component="img"
animate={
shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={
shake
? { x: { duration: 0.5, ease: "easeOut" } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<img
src={ src={
theme === "dark" theme === "dark"
? "/api/admin/company-settings/logo?variant=dark" ? "/api/admin/company-settings/logo?variant=dark"
: "/api/admin/company-settings/logo?variant=light" : "/api/admin/company-settings/logo?variant=light"
} }
alt="Logo" alt="Logo"
className="admin-login-logo"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).src = (e.target as HTMLImageElement).src =
theme === "dark" theme === "dark"
? "/images/logo-dark.png" ? "/images/logo-dark.png"
: "/images/logo-light.png"; : "/images/logo-light.png";
}} }}
sx={{ height: 48, width: "auto", mb: 2 }}
/> />
<h1 className="admin-login-title">Interní systém</h1> <Typography
<p className="admin-login-subtitle">Přihlaste se ke svému účtu</p> variant="h1"
</div> sx={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--text-primary)",
fontFamily: "var(--font-heading)",
mb: 1,
}}
>
Interní systém
</Typography>
<Typography
sx={{ color: "var(--text-secondary)", fontSize: "0.95rem" }}
>
Přihlaste se ke svému účtu
</Typography>
</Box>
<form onSubmit={handleSubmit} className="admin-form"> <Box component="form" onSubmit={handleSubmit}>
<FormField label="Uživatelské jméno nebo e-mail"> <Field label="Uživatelské jméno nebo e-mail">
<input <TextField
id="username" id="username"
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
autoComplete="username" autoComplete="username"
className="admin-form-input"
placeholder="Zadejte uživatelské jméno" placeholder="Zadejte uživatelské jméno"
/> />
</FormField> </Field>
<FormField label="Heslo"> <Field label="Heslo">
<input <TextField
id="password" id="password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
autoComplete="current-password" autoComplete="current-password"
className="admin-form-input"
placeholder="Zadejte heslo" placeholder="Zadejte heslo"
/> />
</FormField> </Field>
<label className="admin-form-checkbox"> <Box sx={{ mb: 2 }}>
<input <CheckboxField
type="checkbox" label="Zapamatovat si mě"
checked={remember} checked={remember}
onChange={(e) => setRemember(e.target.checked)} onChange={setRemember}
/> />
<span>Zapamatovat si </span> </Box>
</label>
<button <Button type="submit" disabled={loading} fullWidth>
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: "100%" }}
>
{loading ? ( {loading ? (
<> <>
<div <CircularProgress
className="admin-spinner" size={20}
style={{ width: 20, height: 20, borderWidth: 2 }} thickness={5}
color="inherit"
sx={{ mr: 1 }}
/> />
Přihlašování... Přihlašování...
</> </>
) : ( ) : (
"Přihlásit se" "Přihlásit se"
)} )}
</button> </Button>
</form> </Box>
</motion.div> </Box>
) : ( ) : (
<motion.div <Box key="2fa" component={motion.div} {...cardMotion} sx={cardSx}>
key="2fa" <Box sx={{ textAlign: "center", mb: 4 }}>
className="admin-login-card" <Box
initial={{ opacity: 0, y: 30 }} sx={{
animate={ display: "inline-flex",
shake alignItems: "center",
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] } justifyContent: "center",
: { opacity: 1, y: 0 } width: 56,
} height: 56,
exit={{ opacity: 0, y: -20 }} borderRadius: "50%",
transition={ background: "var(--accent-light)",
shake color: "var(--accent-color)",
? { x: { duration: 0.5, ease: "easeOut" } } mb: 2,
: { duration: 0.3 } }}
} >
>
<div className="admin-login-header">
<div className="admin-login-2fa-icon">
<svg <svg
width="32" width="32"
height="32" height="32"
@@ -322,26 +404,34 @@ export default function Login() {
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
</div> </Box>
<h1 className="admin-login-title">Dvoufaktorové ověření</h1> <Typography
<p className="admin-login-subtitle"> variant="h1"
sx={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--text-primary)",
fontFamily: "var(--font-heading)",
mb: 1,
}}
>
Dvoufaktorové ověření
</Typography>
<Typography
sx={{ color: "var(--text-secondary)", fontSize: "0.95rem" }}
>
{useBackupCode {useBackupCode
? "Zadejte jeden ze záložních kódů" ? "Zadejte jeden ze záložních kódů"
: "Zadejte 6místný kód z autentizační aplikace"} : "Zadejte 6místný kód z autentizační aplikace"}
</p> </Typography>
</div> </Box>
<form onSubmit={handle2FASubmit} className="admin-form"> <Box component="form" onSubmit={handle2FASubmit}>
<FormField <Field label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}>
label={useBackupCode ? "Záložní kód" : "Ověřovací kód"} <TextField
> inputRef={totpInputRef}
<input
ref={totpInputRef}
id="totp-code" id="totp-code"
type="text" type="text"
inputMode={useBackupCode ? "text" : "numeric"}
pattern={useBackupCode ? undefined : "[0-9]*"}
maxLength={useBackupCode ? 8 : 6}
value={totpCode} value={totpCode}
onChange={(e) => { onChange={(e) => {
const val = useBackupCode const val = useBackupCode
@@ -351,80 +441,93 @@ export default function Login() {
}} }}
required required
autoComplete="one-time-code" autoComplete="one-time-code"
className="admin-form-input"
placeholder={useBackupCode ? "XXXXXXXX" : "000000"} placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
style={ slotProps={{
useBackupCode htmlInput: {
? {} inputMode: useBackupCode ? "text" : "numeric",
: { pattern: useBackupCode ? undefined : "[0-9]*",
textAlign: "center", maxLength: useBackupCode ? 8 : 6,
fontSize: "1.5rem", style: useBackupCode
letterSpacing: "0.5rem", ? {}
fontFamily: "monospace", : {
} textAlign: "center",
} fontSize: "1.5rem",
letterSpacing: "0.5rem",
fontFamily: "monospace",
},
},
}}
/> />
</FormField> </Field>
<button <Button type="submit" disabled={loading} fullWidth>
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: "100%" }}
>
{loading ? ( {loading ? (
<> <>
<div <CircularProgress
className="admin-spinner" size={20}
style={{ width: 20, height: 20, borderWidth: 2 }} thickness={5}
color="inherit"
sx={{ mr: 1 }}
/> />
Ověřování... Ověřování...
</> </>
) : ( ) : (
"Ověřit" "Ověřit"
)} )}
</button> </Button>
</form> </Box>
<div <Box
style={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: "0.5rem", gap: 1,
marginTop: "0.5rem", mt: 1,
}} }}
> >
<button <Button
variant="text"
onClick={() => { onClick={() => {
setUseBackupCode(!useBackupCode); setUseBackupCode(!useBackupCode);
setTotpCode(""); setTotpCode("");
}} }}
className="admin-back-link" sx={{
style={{ color: "var(--text-secondary)",
border: "none", fontSize: "0.875rem",
background: "none", fontWeight: 400,
cursor: "pointer", textTransform: "none",
"&:hover": {
color: "var(--text-primary)",
textDecoration: "underline",
background: "transparent",
},
}} }}
> >
{useBackupCode {useBackupCode
? "Použít autentizační aplikaci" ? "Použít autentizační aplikaci"
: "Použít záložní kód"} : "Použít záložní kód"}
</button> </Button>
<button <Button
variant="text"
onClick={handleBack} onClick={handleBack}
className="admin-back-link" sx={{
style={{ color: "var(--text-secondary)",
border: "none", fontSize: "0.875rem",
background: "none", fontWeight: 400,
cursor: "pointer", textTransform: "none",
"&:hover": {
color: "var(--text-primary)",
textDecoration: "underline",
background: "transparent",
},
}} }}
> >
&larr; Zpět na přihlášení &larr; Zpět na přihlášení
</button> </Button>
</div> </Box>
</motion.div> </Box>
)} )}
</AnimatePresence> </AnimatePresence>
</motion.div> </Box>
); );
} }