Files
app/src/admin/pages/Login.tsx
BOHA 03285c6ff6 refactor(css): migrate Login + file icons off legacy vars, delete variables.css
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>
2026-06-07 17:45:36 +02:00

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",
},
}}
>
&larr; Zpět na přihlášení
</Button>
</Box>
</Box>
)}
</AnimatePresence>
</Box>
);
}