Phase 4 (final) of the "fully MUI" cleanup — no custom stylesheets remain
in src/admin (only the third-party quill.snow.css + leaflet.css imports).
- Login.tsx: every var(--glass-*/bg-*/text-*/accent-*/orb-*/border-*/
font-*/transition) in its sx/inline styles is now an MUI token
(bgcolor/color/borderColor: "background.paper" / "text.secondary" /
"divider" / "primary.main"), a channel-alpha (rgba(var(--mui-palette-
primary-mainChannel) / a)) for tints, or theme.applyStyles("dark", …)
for the glass-card shadow. The card sx is now a (theme)=>({}) callback.
- Restores the orb drift animation via the MUI keyframes() helper — the
@keyframes float had been dropped in Phase 1 (the dead-CSS scan missed
its use inside an sx animation string); recovered verbatim from git.
- ProjectFileManager: the fallback file-icon color uses
var(--mui-palette-text-secondary).
- variables.css deleted; its import removed from AdminApp (last one).
tsc -b --noEmit + npm run build clean. applyStyles dark-scheme output and
the channel-alpha pattern were both verified live in Chrome via the Quill
editor, which uses the identical constructs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
542 lines
16 KiB
TypeScript
542 lines
16 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { Navigate } from "react-router-dom";
|
|
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 { keyframes } from "@mui/system";
|
|
import type { Theme } from "@mui/material/styles";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import { useAlert } from "../context/AlertContext";
|
|
import { useTheme } from "../../context/ThemeContext";
|
|
import {
|
|
shouldShowSessionExpiredAlert,
|
|
shouldShowLogoutAlert,
|
|
} from "../utils/api";
|
|
import { TextField, Button, CheckboxField, Field } from "../ui";
|
|
|
|
// Drift animation for the decorative background orbs (was @keyframes float).
|
|
const float = keyframes({
|
|
"0%, 100%": { transform: "translate(0, 0)" },
|
|
"50%": { transform: "translate(30px, -30px)" },
|
|
});
|
|
|
|
export default function Login() {
|
|
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
|
|
const alert = useAlert();
|
|
const { theme, toggleTheme } = useTheme();
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [remember, setRemember] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [shake, setShake] = useState(false);
|
|
const [animatingOut, setAnimatingOut] = useState(false);
|
|
|
|
// 2FA state
|
|
const [show2FA, setShow2FA] = useState(false);
|
|
const [loginToken, setLoginToken] = useState<string | null>(null);
|
|
const [totpCode, setTotpCode] = useState("");
|
|
const [useBackupCode, setUseBackupCode] = useState(false);
|
|
const totpInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (shouldShowSessionExpiredAlert()) {
|
|
alert.warning("Vaše relace vypršela. Přihlaste se prosím znovu.");
|
|
} else if (shouldShowLogoutAlert()) {
|
|
alert.success("Byli jste úspěšně odhlášeni.");
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Auto-focus TOTP input
|
|
useEffect(() => {
|
|
if (show2FA && totpInputRef.current) {
|
|
totpInputRef.current.focus();
|
|
}
|
|
}, [show2FA, useBackupCode]);
|
|
|
|
const handleSubmit = useCallback(
|
|
async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
|
|
const result = await login(username, password, remember);
|
|
|
|
if (result.requires2FA) {
|
|
setLoginToken(result.loginToken ?? null);
|
|
setShow2FA(true);
|
|
setTotpCode("");
|
|
setLoading(false);
|
|
} else if (!result.success) {
|
|
alert.error(result.error ?? "Chyba přihlášení");
|
|
setShake(true);
|
|
setTimeout(() => setShake(false), 500);
|
|
setLoading(false);
|
|
} else {
|
|
alert.success("Úspěšně přihlášeno");
|
|
setAnimatingOut(true);
|
|
setTimeout(() => setAnimatingOut(false), 400);
|
|
}
|
|
},
|
|
[
|
|
username,
|
|
password,
|
|
remember,
|
|
login,
|
|
alert,
|
|
setLoading,
|
|
setShake,
|
|
setAnimatingOut,
|
|
setLoginToken,
|
|
setShow2FA,
|
|
setTotpCode,
|
|
],
|
|
);
|
|
|
|
const handle2FASubmit = useCallback(
|
|
async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!totpCode.trim()) return;
|
|
|
|
setLoading(true);
|
|
|
|
const result = await verify2FA(
|
|
loginToken!,
|
|
totpCode.trim(),
|
|
remember,
|
|
useBackupCode,
|
|
);
|
|
|
|
if (!result.success) {
|
|
alert.error(result.error ?? "Chyba ověření");
|
|
setShake(true);
|
|
setTimeout(() => setShake(false), 500);
|
|
setTotpCode("");
|
|
if (totpInputRef.current) totpInputRef.current.focus();
|
|
setLoading(false);
|
|
} else {
|
|
alert.success("Úspěšně přihlášeno");
|
|
setAnimatingOut(true);
|
|
setTimeout(() => setAnimatingOut(false), 400);
|
|
}
|
|
},
|
|
[
|
|
totpCode,
|
|
loginToken,
|
|
remember,
|
|
useBackupCode,
|
|
verify2FA,
|
|
alert,
|
|
setLoading,
|
|
setShake,
|
|
setTotpCode,
|
|
setAnimatingOut,
|
|
],
|
|
);
|
|
|
|
const handleBack = useCallback(() => {
|
|
setShow2FA(false);
|
|
setLoginToken(null);
|
|
setTotpCode("");
|
|
setUseBackupCode(false);
|
|
}, [setShow2FA, setLoginToken, setTotpCode, setUseBackupCode]);
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<Box
|
|
sx={{
|
|
minHeight: ["100vh", "100dvh"],
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
bgcolor: "background.default",
|
|
}}
|
|
>
|
|
<CircularProgress />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (isAuthenticated && !animatingOut) {
|
|
return <Navigate to="/" replace />;
|
|
}
|
|
|
|
// Shared glass card sx (replaces .admin-login-card)
|
|
const cardSx = (theme: Theme) => ({
|
|
width: "100%",
|
|
maxWidth: 420,
|
|
maxHeight: "calc(100dvh - 2rem)",
|
|
p: { xs: 4, sm: 5 },
|
|
bgcolor: "background.paper",
|
|
backdropFilter: "blur(20px)",
|
|
border: "1px solid",
|
|
borderColor: "divider",
|
|
borderRadius: "16px",
|
|
boxShadow: "0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.04)",
|
|
...theme.applyStyles("dark", {
|
|
boxShadow: "0 1px 3px rgba(0,0,0,0.2), 0 4px 16px rgba(0,0,0,0.15)",
|
|
}),
|
|
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 (
|
|
<Box
|
|
component={motion.div}
|
|
initial={{ opacity: 0, scale: 0.98 }}
|
|
animate={
|
|
animatingOut
|
|
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
|
: { scale: 1, opacity: 1, filter: "none" }
|
|
}
|
|
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
|
sx={{
|
|
minHeight: ["100vh", "100dvh"],
|
|
maxHeight: ["100vh", "100dvh"],
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
p: 2,
|
|
bgcolor: "background.default",
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
boxSizing: "border-box",
|
|
}}
|
|
>
|
|
{/* Animated background orbs (replaces .bg-orb) */}
|
|
<Box
|
|
aria-hidden
|
|
sx={{
|
|
position: "absolute",
|
|
borderRadius: "50%",
|
|
filter: "blur(80px)",
|
|
pointerEvents: "none",
|
|
width: 400,
|
|
height: 400,
|
|
background: "rgba(var(--mui-palette-primary-mainChannel) / 0.16)",
|
|
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: "rgba(120, 119, 198, 0.13)",
|
|
bottom: "20%",
|
|
right: "15%",
|
|
animation: `${float} 25s ease-in-out infinite reverse`,
|
|
}}
|
|
/>
|
|
|
|
{/* Theme toggle (replaces .admin-login-theme-btn) */}
|
|
<IconButton
|
|
onClick={toggleTheme}
|
|
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
|
sx={{
|
|
position: "absolute",
|
|
top: "1rem",
|
|
right: "1rem",
|
|
width: 44,
|
|
height: 44,
|
|
bgcolor: "background.paper",
|
|
backdropFilter: "blur(10px)",
|
|
border: "1px solid",
|
|
borderColor: "divider",
|
|
color: "text.secondary",
|
|
overflow: "hidden",
|
|
zIndex: 10,
|
|
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
|
"&:hover": {
|
|
bgcolor: "background.paper",
|
|
color: "text.primary",
|
|
borderColor: "rgba(var(--mui-palette-text-primaryChannel) / 0.15)",
|
|
transform: "scale(1.05)",
|
|
},
|
|
}}
|
|
>
|
|
{theme === "light" ? (
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<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" />
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
</svg>
|
|
)}
|
|
</IconButton>
|
|
|
|
<AnimatePresence mode="wait">
|
|
{!show2FA ? (
|
|
<Box key="login" component={motion.div} {...cardMotion} sx={cardSx}>
|
|
<Box sx={{ textAlign: "center", mb: 4 }}>
|
|
<Box
|
|
component="img"
|
|
src={
|
|
theme === "dark"
|
|
? "/api/admin/company-settings/logo?variant=dark"
|
|
: "/api/admin/company-settings/logo?variant=light"
|
|
}
|
|
alt="Logo"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src =
|
|
theme === "dark"
|
|
? "/images/logo-dark.png"
|
|
: "/images/logo-light.png";
|
|
}}
|
|
sx={{ height: 48, width: "auto", mb: 2 }}
|
|
/>
|
|
<Typography
|
|
variant="h1"
|
|
sx={{
|
|
fontSize: "1.5rem",
|
|
fontWeight: 700,
|
|
color: "text.primary",
|
|
mb: 1,
|
|
}}
|
|
>
|
|
Interní systém
|
|
</Typography>
|
|
<Typography sx={{ color: "text.secondary", fontSize: "0.95rem" }}>
|
|
Přihlaste se ke svému účtu
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box component="form" onSubmit={handleSubmit}>
|
|
<Field label="Uživatelské jméno nebo e-mail">
|
|
<TextField
|
|
id="username"
|
|
type="text"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
required
|
|
autoComplete="username"
|
|
placeholder="Zadejte uživatelské jméno"
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Heslo">
|
|
<TextField
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
autoComplete="current-password"
|
|
placeholder="Zadejte heslo"
|
|
/>
|
|
</Field>
|
|
|
|
<Box sx={{ mb: 2 }}>
|
|
<CheckboxField
|
|
label="Zapamatovat si mě"
|
|
checked={remember}
|
|
onChange={setRemember}
|
|
/>
|
|
</Box>
|
|
|
|
<Button type="submit" disabled={loading} fullWidth>
|
|
{loading ? (
|
|
<>
|
|
<CircularProgress
|
|
size={20}
|
|
thickness={5}
|
|
color="inherit"
|
|
sx={{ mr: 1 }}
|
|
/>
|
|
Přihlašování...
|
|
</>
|
|
) : (
|
|
"Přihlásit se"
|
|
)}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
) : (
|
|
<Box key="2fa" component={motion.div} {...cardMotion} sx={cardSx}>
|
|
<Box sx={{ textAlign: "center", mb: 4 }}>
|
|
<Box
|
|
sx={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: "50%",
|
|
background:
|
|
"rgba(var(--mui-palette-primary-mainChannel) / 0.1)",
|
|
color: "primary.main",
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<svg
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
>
|
|
<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>
|
|
<Typography
|
|
variant="h1"
|
|
sx={{
|
|
fontSize: "1.5rem",
|
|
fontWeight: 700,
|
|
color: "text.primary",
|
|
mb: 1,
|
|
}}
|
|
>
|
|
Dvoufaktorové ověření
|
|
</Typography>
|
|
<Typography sx={{ color: "text.secondary", fontSize: "0.95rem" }}>
|
|
{useBackupCode
|
|
? "Zadejte jeden ze záložních kódů"
|
|
: "Zadejte 6místný kód z autentizační aplikace"}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box component="form" onSubmit={handle2FASubmit}>
|
|
<Field label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}>
|
|
<TextField
|
|
inputRef={totpInputRef}
|
|
id="totp-code"
|
|
type="text"
|
|
value={totpCode}
|
|
onChange={(e) => {
|
|
const val = useBackupCode
|
|
? e.target.value
|
|
: e.target.value.replace(/\D/g, "");
|
|
setTotpCode(val);
|
|
}}
|
|
required
|
|
autoComplete="one-time-code"
|
|
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
|
|
slotProps={{
|
|
htmlInput: {
|
|
inputMode: useBackupCode ? "text" : "numeric",
|
|
pattern: useBackupCode ? undefined : "[0-9]*",
|
|
maxLength: useBackupCode ? 8 : 6,
|
|
style: useBackupCode
|
|
? {}
|
|
: {
|
|
textAlign: "center",
|
|
fontSize: "1.5rem",
|
|
letterSpacing: "0.5rem",
|
|
fontFamily: "monospace",
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</Field>
|
|
|
|
<Button type="submit" disabled={loading} fullWidth>
|
|
{loading ? (
|
|
<>
|
|
<CircularProgress
|
|
size={20}
|
|
thickness={5}
|
|
color="inherit"
|
|
sx={{ mr: 1 }}
|
|
/>
|
|
Ověřování...
|
|
</>
|
|
) : (
|
|
"Ověřit"
|
|
)}
|
|
</Button>
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 1,
|
|
mt: 1,
|
|
}}
|
|
>
|
|
<Button
|
|
variant="text"
|
|
onClick={() => {
|
|
setUseBackupCode(!useBackupCode);
|
|
setTotpCode("");
|
|
}}
|
|
sx={{
|
|
color: "text.secondary",
|
|
fontSize: "0.875rem",
|
|
fontWeight: 400,
|
|
textTransform: "none",
|
|
"&:hover": {
|
|
color: "text.primary",
|
|
textDecoration: "underline",
|
|
background: "transparent",
|
|
},
|
|
}}
|
|
>
|
|
{useBackupCode
|
|
? "Použít autentizační aplikaci"
|
|
: "Použít záložní kód"}
|
|
</Button>
|
|
<Button
|
|
variant="text"
|
|
onClick={handleBack}
|
|
sx={{
|
|
color: "text.secondary",
|
|
fontSize: "0.875rem",
|
|
fontWeight: 400,
|
|
textTransform: "none",
|
|
"&:hover": {
|
|
color: "text.primary",
|
|
textDecoration: "underline",
|
|
background: "transparent",
|
|
},
|
|
}}
|
|
>
|
|
← Zpět na přihlášení
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</AnimatePresence>
|
|
</Box>
|
|
);
|
|
}
|