Files
app/src/admin/pages/Login.tsx
BOHA aa6c1b5094 refactor: fix all Low findings from FLAWS_REPORT audit
- Auth: TOTP params from config, JWT error logging, audit log failure
  logging, replaced_by_hash validation on token rotation
- Invoices: remove dead VAT code, consistent PDF permissions,
  WebP magic-byte detection, deduped exchange-rate fetches
- Orders/Offers: multipart limit from config, use paginated() helper,
  payment method from DB in PDF
- Projects: verify project exists before creating note
- Attendance: action_type enum validation, consistent local-time
  shift_date construction, holiday attendance in work fund,
  trips.view permission on last-km query
- Users: paginated() helper usage, remove duplicate dashboard keys,
  parallel currency conversion, single hashToken implementation
- Frontend: memoized customInput, reliable print onload, modal prop
  standardization (isOpen), ConfirmModal type icons, id===0 key
  fallback, Login useCallback, CompanySettings ConfirmModal,
  Attendance timeout cleanup, Dashboard memoization, beforeunload
  dirty-state warnings on Invoice/Offer/Order detail
- Schema: invoice_alert_log timestamp, config/env comment on
  Date.prototype.toJSON override
- Utils: exchange-rate inflight dedup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:45:37 +02:00

430 lines
13 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import { Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import { useTheme } from "../../context/ThemeContext";
import {
shouldShowSessionExpiredAlert,
shouldShowLogoutAlert,
} from "../utils/api";
import FormField from "../components/FormField";
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.");
}
}, [alert]);
// Auto-focus TOTP input
useEffect(() => {
if (show2FA && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [show2FA, useBackupCode]);
if (authLoading) {
return (
<div className="admin-login">
<div className="admin-loading">
<div className="admin-spinner" />
</div>
</div>
);
}
if (isAuthenticated && !animatingOut) {
return <Navigate to="/" replace />;
}
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]);
return (
<motion.div
className="admin-login"
initial={{ opacity: 0, scale: 0.98 }}
animate={
animatingOut
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
: { scale: 1, opacity: 1, filter: "none" }
}
transition={
animatingOut
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
}
>
<div className="bg-orb bg-orb-1" />
<div className="bg-orb bg-orb-2" />
<button
onClick={toggleTheme}
className="admin-login-theme-btn"
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
>
<span
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
>
<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>
</span>
<span
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
>
<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>
</span>
</button>
<AnimatePresence mode="wait">
{!show2FA ? (
<motion.div
key="login"
className="admin-login-card"
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" } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<img
src={
theme === "dark"
? "/api/admin/company-settings/logo?variant=dark"
: "/api/admin/company-settings/logo?variant=light"
}
alt="Logo"
className="admin-login-logo"
onError={(e) => {
(e.target as HTMLImageElement).src =
theme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png";
}}
/>
<h1 className="admin-login-title">Interní systém</h1>
<p className="admin-login-subtitle">Přihlaste se ke svému účtu</p>
</div>
<form onSubmit={handleSubmit} className="admin-form">
<FormField label="Uživatelské jméno nebo e-mail">
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
className="admin-form-input"
placeholder="Zadejte uživatelské jméno"
/>
</FormField>
<FormField label="Heslo">
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="admin-form-input"
placeholder="Zadejte heslo"
/>
</FormField>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
<span>Zapamatovat si </span>
</label>
<button
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: "100%" }}
>
{loading ? (
<>
<div
className="admin-spinner"
style={{ width: 20, height: 20, borderWidth: 2 }}
/>
Přihlašování...
</>
) : (
"Přihlásit se"
)}
</button>
</form>
</motion.div>
) : (
<motion.div
key="2fa"
className="admin-login-card"
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" } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<div className="admin-login-2fa-icon">
<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>
</div>
<h1 className="admin-login-title">Dvoufaktorové ověření</h1>
<p className="admin-login-subtitle">
{useBackupCode
? "Zadejte jeden ze záložních kódů"
: "Zadejte 6místný kód z autentizační aplikace"}
</p>
</div>
<form onSubmit={handle2FASubmit} className="admin-form">
<FormField
label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}
>
<input
ref={totpInputRef}
id="totp-code"
type="text"
inputMode={useBackupCode ? "text" : "numeric"}
pattern={useBackupCode ? undefined : "[0-9]*"}
maxLength={useBackupCode ? 8 : 6}
value={totpCode}
onChange={(e) => {
const val = useBackupCode
? e.target.value
: e.target.value.replace(/\D/g, "");
setTotpCode(val);
}}
required
autoComplete="one-time-code"
className="admin-form-input"
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
style={
useBackupCode
? {}
: {
textAlign: "center",
fontSize: "1.5rem",
letterSpacing: "0.5rem",
fontFamily: "monospace",
}
}
/>
</FormField>
<button
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: "100%" }}
>
{loading ? (
<>
<div
className="admin-spinner"
style={{ width: 20, height: 20, borderWidth: 2 }}
/>
Ověřování...
</>
) : (
"Ověřit"
)}
</button>
</form>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.5rem",
}}
>
<button
onClick={() => {
setUseBackupCode(!useBackupCode);
setTotpCode("");
}}
className="admin-back-link"
style={{
border: "none",
background: "none",
cursor: "pointer",
}}
>
{useBackupCode
? "Použít autentizační aplikaci"
: "Použít záložní kód"}
</button>
<button
onClick={handleBack}
className="admin-back-link"
style={{
border: "none",
background: "none",
cursor: "pointer",
}}
>
&larr; Zpět na přihlášení
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}