- 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>
430 lines
13 KiB
TypeScript
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 mě</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",
|
|
}}
|
|
>
|
|
← Zpět na přihlášení
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
}
|