initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:46:51 +01:00
commit 4608494a3f
130 changed files with 40361 additions and 0 deletions

321
src/admin/pages/Login.tsx Normal file
View File

@@ -0,0 +1,321 @@
import { useState, useEffect, useRef } 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 = 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)
}
}
const handle2FASubmit = 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)
}
}
const handleBack = () => {
setShow2FA(false)
setLoginToken(null)
setTotpCode('')
setUseBackupCode(false)
}
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' ? '/images/logo-dark.png' : '/images/logo-light.png'}
alt="Logo"
className="admin-login-logo"
/>
<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>
)
}