mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
47b880221d
Three distinct bugs caused infinite OIDC redirect loops:
1. After logout, navigating to /login with no signal to suppress the
auto-redirect caused the login page to immediately re-trigger the
OIDC flow. Fixed by passing `{ state: { noRedirect: true } }` via
React Router's navigation state (not URL params, which were fragile
due to async cleanup timing) from all logout call sites.
2. On the OIDC callback page (/login?oidc_code=...), App.tsx's
mount-level loadUser() fired concurrently with the LoginPage's
exchange fetch. The App-level call had no cookie yet and got a 401,
which (if it resolved after the successful exchange loadUser()) would
overwrite isAuthenticated back to false. Fixed by skipping loadUser()
in App.tsx when the initial path is /login.
3. React 18 StrictMode double-invokes useEffect. The first run called
window.history.replaceState to clean the oidc_code from the URL
before starting the async exchange, so the second run saw no
oidc_code and fell through to the getAppConfig auto-redirect, firing
window.location.href = '/api/auth/oidc/login' before the exchange
could complete. Fixed by adding a useRef guard to prevent
double-execution and moving replaceState into the fetch callbacks so
the URL is only cleaned after the exchange resolves.
Also adds login.oidcLoggedOut translation key in all 14 languages to
show "You have been logged out" instead of the generic OIDC-only
message when landing on /login after an intentional logout.
Closes #491
840 lines
42 KiB
TypeScript
840 lines
42 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
|
import { useNavigate, useLocation } from 'react-router-dom'
|
|
import { useAuthStore } from '../store/authStore'
|
|
import { useSettingsStore } from '../store/settingsStore'
|
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
|
import { authApi } from '../api/client'
|
|
import { getApiErrorMessage } from '../types'
|
|
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react'
|
|
|
|
interface AppConfig {
|
|
has_users: boolean
|
|
allow_registration: boolean
|
|
setup_complete: boolean
|
|
demo_mode: boolean
|
|
oidc_configured: boolean
|
|
oidc_display_name?: string
|
|
oidc_only_mode: boolean
|
|
}
|
|
|
|
export default function LoginPage(): React.ReactElement {
|
|
const { t, language } = useTranslation()
|
|
const [mode, setMode] = useState<'login' | 'register'>('login')
|
|
const [username, setUsername] = useState<string>('')
|
|
const [email, setEmail] = useState<string>('')
|
|
const [password, setPassword] = useState<string>('')
|
|
const [showPassword, setShowPassword] = useState<boolean>(false)
|
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
|
const [error, setError] = useState<string>('')
|
|
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
|
const [inviteToken, setInviteToken] = useState<string>('')
|
|
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
|
const exchangeInitiated = useRef(false)
|
|
|
|
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
|
|
const { setLanguageLocal } = useSettingsStore()
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect
|
|
|
|
const redirectTarget = useMemo(() => {
|
|
const params = new URLSearchParams(window.location.search)
|
|
const redirect = params.get('redirect')
|
|
// Only allow relative paths starting with / to prevent open redirect attacks
|
|
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')) {
|
|
return redirect
|
|
}
|
|
return '/dashboard'
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search)
|
|
|
|
const invite = params.get('invite')
|
|
const oidcCode = params.get('oidc_code')
|
|
const oidcError = params.get('oidc_error')
|
|
|
|
if (invite) {
|
|
setInviteToken(invite)
|
|
setMode('register')
|
|
authApi.validateInvite(invite).then(() => {
|
|
setInviteValid(true)
|
|
}).catch(() => {
|
|
setError('Invalid or expired invite link')
|
|
})
|
|
window.history.replaceState({}, '', window.location.pathname)
|
|
}
|
|
|
|
if (oidcCode) {
|
|
if (exchangeInitiated.current) return
|
|
exchangeInitiated.current = true
|
|
setIsLoading(true)
|
|
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' })
|
|
.then(r => r.json())
|
|
.then(async data => {
|
|
window.history.replaceState({}, '', '/login')
|
|
if (data.token) {
|
|
await loadUser()
|
|
navigate('/dashboard', { replace: true })
|
|
} else {
|
|
setError(data.error || 'OIDC login failed')
|
|
}
|
|
})
|
|
.catch(() => {
|
|
window.history.replaceState({}, '', '/login')
|
|
setError('OIDC login failed')
|
|
})
|
|
.finally(() => setIsLoading(false))
|
|
return
|
|
}
|
|
|
|
if (oidcError) {
|
|
const errorMessages: Record<string, string> = {
|
|
registration_disabled: t('login.oidc.registrationDisabled'),
|
|
no_email: t('login.oidc.noEmail'),
|
|
token_failed: t('login.oidc.tokenFailed'),
|
|
invalid_state: t('login.oidc.invalidState'),
|
|
}
|
|
setError(errorMessages[oidcError] || oidcError)
|
|
window.history.replaceState({}, '', '/login')
|
|
return
|
|
}
|
|
|
|
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
|
if (config) {
|
|
setAppConfig(config)
|
|
if (!config.has_users) setMode('register')
|
|
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
|
window.location.href = '/api/auth/oidc/login'
|
|
}
|
|
}
|
|
})
|
|
}, [navigate, t, noRedirect])
|
|
|
|
const handleDemoLogin = async (): Promise<void> => {
|
|
setError('')
|
|
setIsLoading(true)
|
|
try {
|
|
await demoLogin()
|
|
setShowTakeoff(true)
|
|
setTimeout(() => navigate(redirectTarget), 2600)
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : t('login.demoFailed'))
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
|
|
const [mfaStep, setMfaStep] = useState(false)
|
|
const [mfaToken, setMfaToken] = useState('')
|
|
const [mfaCode, setMfaCode] = useState('')
|
|
const [passwordChangeStep, setPasswordChangeStep] = useState(false)
|
|
const [savedLoginPassword, setSavedLoginPassword] = useState('')
|
|
const [newPassword, setNewPassword] = useState('')
|
|
const [confirmPassword, setConfirmPassword] = useState('')
|
|
|
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
|
e.preventDefault()
|
|
setError('')
|
|
setIsLoading(true)
|
|
try {
|
|
if (passwordChangeStep) {
|
|
if (!newPassword) { setError(t('settings.passwordRequired')); setIsLoading(false); return }
|
|
if (newPassword.length < 8) { setError(t('settings.passwordTooShort')); setIsLoading(false); return }
|
|
if (newPassword !== confirmPassword) { setError(t('settings.passwordMismatch')); setIsLoading(false); return }
|
|
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
|
|
await loadUser({ silent: true })
|
|
setShowTakeoff(true)
|
|
setTimeout(() => navigate(redirectTarget), 2600)
|
|
return
|
|
}
|
|
if (mode === 'login' && mfaStep) {
|
|
if (!mfaCode.trim()) {
|
|
setError(t('login.mfaCodeRequired'))
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
|
|
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
|
|
setSavedLoginPassword(password)
|
|
setPasswordChangeStep(true)
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
setShowTakeoff(true)
|
|
setTimeout(() => navigate(redirectTarget), 2600)
|
|
return
|
|
}
|
|
if (mode === 'register') {
|
|
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
|
|
if (password.length < 8) { setError('Password must be at least 8 characters'); setIsLoading(false); return }
|
|
await register(username, email, password, inviteToken || undefined)
|
|
} else {
|
|
const result = await login(email, password)
|
|
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
|
setMfaToken(result.mfa_token)
|
|
setMfaStep(true)
|
|
setMfaCode('')
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
if ('user' in result && result.user?.must_change_password) {
|
|
setSavedLoginPassword(password)
|
|
setPasswordChangeStep(true)
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
}
|
|
setShowTakeoff(true)
|
|
setTimeout(() => navigate(redirectTarget), 2600)
|
|
} catch (err: unknown) {
|
|
setError(getApiErrorMessage(err, t('login.error')))
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode && (appConfig?.setup_complete !== false || !appConfig?.has_users)
|
|
|
|
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
|
|
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
|
|
|
|
const inputBase: React.CSSProperties = {
|
|
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
|
|
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none',
|
|
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
|
|
}
|
|
|
|
if (showTakeoff) {
|
|
return (
|
|
<div className="takeoff-overlay" style={{ position: 'fixed', inset: 0, zIndex: 99999, overflow: 'hidden' }}>
|
|
{/* Sky gradient */}
|
|
<div className="takeoff-sky" style={{ position: 'absolute', inset: 0 }} />
|
|
|
|
{/* Stars */}
|
|
{Array.from({ length: 60 }, (_, i) => (
|
|
<div key={i} className="takeoff-star" style={{
|
|
position: 'absolute',
|
|
width: Math.random() > 0.7 ? 3 : 1.5,
|
|
height: Math.random() > 0.7 ? 3 : 1.5,
|
|
borderRadius: '50%',
|
|
background: 'white',
|
|
top: `${Math.random() * 100}%`,
|
|
left: `${Math.random() * 100}%`,
|
|
animationDelay: `${0.3 + Math.random() * 0.5}s, ${Math.random() * 1}s`,
|
|
}} />
|
|
))}
|
|
|
|
{/* Clouds rushing past */}
|
|
{[0, 1, 2, 3, 4].map(i => (
|
|
<div key={i} className="takeoff-cloud" style={{
|
|
position: 'absolute',
|
|
width: 120 + i * 40,
|
|
height: 40 + i * 10,
|
|
borderRadius: '50%',
|
|
background: 'rgba(255,255,255,0.15)',
|
|
filter: 'blur(8px)',
|
|
right: -200,
|
|
top: `${25 + i * 12}%`,
|
|
animationDelay: `${0.3 + i * 0.25}s`,
|
|
}} />
|
|
))}
|
|
|
|
{/* Speed lines */}
|
|
{Array.from({ length: 12 }, (_, i) => (
|
|
<div key={i} className="takeoff-speedline" style={{
|
|
position: 'absolute',
|
|
width: 80 + Math.random() * 120,
|
|
height: 1.5,
|
|
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
|
|
top: `${10 + Math.random() * 80}%`,
|
|
right: -200,
|
|
animationDelay: `${0.5 + i * 0.12}s`,
|
|
}} />
|
|
))}
|
|
|
|
{/* Plane */}
|
|
<div className="takeoff-plane" style={{ position: 'absolute', left: '50%', bottom: '10%', transform: 'translate(-50%, 0)' }}>
|
|
<svg viewBox="0 0 480 120" style={{ width: 200, filter: 'drop-shadow(0 0 20px rgba(255,255,255,0.3))' }}>
|
|
<g fill="white" transform="translate(240,60) rotate(-12)">
|
|
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
|
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
|
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
|
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
|
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
|
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
|
|
{/* Contrail */}
|
|
<div className="takeoff-trail" style={{
|
|
position: 'absolute', left: '50%', bottom: '8%',
|
|
width: 3, height: 0, background: 'linear-gradient(to top, transparent, rgba(255,255,255,0.5))',
|
|
transformOrigin: 'bottom center',
|
|
}} />
|
|
|
|
{/* Logo fade in + burst */}
|
|
<div className="takeoff-logo" style={{
|
|
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
|
|
}}>
|
|
<img src="/logo-light.svg" alt="TREK" style={{ height: 72 }} />
|
|
<p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
|
|
</div>
|
|
|
|
|
|
<style>{`
|
|
.takeoff-sky {
|
|
background: linear-gradient(to top, #1a1a2e 0%, #16213e 30%, #0f3460 60%, #0a0a23 100%);
|
|
animation: skyShift 2.6s ease-in-out forwards;
|
|
}
|
|
@keyframes skyShift {
|
|
0% { background: linear-gradient(to top, #0a0a23 0%, #0f172a 40%, #111827 100%); }
|
|
100% { background: linear-gradient(to top, #000011 0%, #000016 50%, #000011 100%); }
|
|
}
|
|
|
|
.takeoff-star {
|
|
opacity: 0;
|
|
animation: starAppear 0.5s ease-out forwards, starTwinkle 2s ease-in-out infinite alternate;
|
|
}
|
|
@keyframes starAppear {
|
|
0% { opacity: 0; transform: scale(0); }
|
|
100% { opacity: 0.7; transform: scale(1); }
|
|
}
|
|
@keyframes starTwinkle {
|
|
0% { opacity: 0.3; }
|
|
100% { opacity: 0.9; }
|
|
}
|
|
|
|
.takeoff-cloud {
|
|
animation: cloudRush 0.6s ease-in forwards;
|
|
}
|
|
@keyframes cloudRush {
|
|
0% { right: -200px; opacity: 0; }
|
|
20% { opacity: 0.4; }
|
|
100% { right: 120%; opacity: 0; }
|
|
}
|
|
|
|
.takeoff-speedline {
|
|
animation: speedRush 0.4s ease-in forwards;
|
|
}
|
|
@keyframes speedRush {
|
|
0% { right: -200px; opacity: 0; }
|
|
30% { opacity: 0.6; }
|
|
100% { right: 120%; opacity: 0; }
|
|
}
|
|
|
|
.takeoff-plane {
|
|
animation: planeUp 1s ease-in forwards;
|
|
}
|
|
@keyframes planeUp {
|
|
0% { transform: translate(-50%, 0) rotate(0deg) scale(1); bottom: 8%; left: 50%; opacity: 1; }
|
|
100% { transform: translate(-50%, 0) rotate(-22deg) scale(0.15); bottom: 120%; left: 58%; opacity: 0; }
|
|
}
|
|
|
|
.takeoff-trail {
|
|
animation: trailGrow 0.9s ease-out 0.15s forwards;
|
|
}
|
|
@keyframes trailGrow {
|
|
0% { height: 0; opacity: 0; transform: translateX(-50%) rotate(-5deg); }
|
|
30% { height: 150px; opacity: 0.6; }
|
|
60% { height: 350px; opacity: 0.4; }
|
|
100% { height: 600px; opacity: 0; transform: translateX(-50%) rotate(-8deg); }
|
|
}
|
|
|
|
.takeoff-logo {
|
|
opacity: 0;
|
|
animation: logoReveal 0.5s ease-out 0.9s forwards;
|
|
}
|
|
@keyframes logoReveal {
|
|
0% { opacity: 0; transform: translate(-50%, -40%) scale(0.9); }
|
|
100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
|
|
|
{/* Language toggle */}
|
|
<button
|
|
onClick={() => {
|
|
const languages = SUPPORTED_LANGUAGES.map(({ value }) => value)
|
|
const currentIndex = languages.findIndex(code => code === language)
|
|
const nextLanguage = languages[(currentIndex + 1) % languages.length]
|
|
setLanguageLocal(nextLanguage)
|
|
}}
|
|
style={{
|
|
position: 'absolute', top: 16, right: 16, zIndex: 10,
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
padding: '6px 12px', borderRadius: 99,
|
|
background: 'rgba(0,0,0,0.06)', border: 'none',
|
|
fontSize: 13, fontWeight: 500, color: '#374151',
|
|
cursor: 'pointer', fontFamily: 'inherit',
|
|
transition: 'background 0.15s',
|
|
}}
|
|
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
|
|
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
|
|
>
|
|
<Globe size={14} />
|
|
{language.toUpperCase()}
|
|
</button>
|
|
|
|
{/* Left — branding */}
|
|
<div style={{ display: 'none', width: '55%', background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '60px 48px', position: 'relative', overflow: 'hidden' }}
|
|
className="lg-panel">
|
|
<style>{`@media(min-width:1024px){.lg-panel{display:flex!important}}`}</style>
|
|
|
|
{/* Stars */}
|
|
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
|
{Array.from({ length: 40 }, (_, i) => (
|
|
<div key={i} className="login-star" style={{
|
|
position: 'absolute',
|
|
width: Math.random() > 0.7 ? 2 : 1,
|
|
height: Math.random() > 0.7 ? 2 : 1,
|
|
borderRadius: '50%',
|
|
background: 'white',
|
|
opacity: 0.15 + Math.random() * 0.25,
|
|
top: `${Math.random() * 70}%`,
|
|
left: `${Math.random() * 100}%`,
|
|
animationDelay: `${Math.random() * 4}s`,
|
|
}} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Animated glow orbs */}
|
|
<div className="login-orb1" style={{ position: 'absolute', width: 500, height: 500, borderRadius: '50%', background: 'radial-gradient(circle, rgba(99,102,241,0.1) 0%, transparent 70%)', filter: 'blur(80px)' }} />
|
|
<div className="login-orb2" style={{ position: 'absolute', width: 350, height: 350, borderRadius: '50%', background: 'radial-gradient(circle, rgba(14,165,233,0.08) 0%, transparent 70%)', filter: 'blur(60px)' }} />
|
|
|
|
{/* Animated planes — realistic silhouettes at different sizes/speeds */}
|
|
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}>
|
|
{/* Plane 1 — large, slow, foreground */}
|
|
<svg className="login-plane1" viewBox="0 0 480 120" style={{ position: 'absolute', width: 48, opacity: 0.12 }}>
|
|
<g fill="white" transform="translate(240,60) rotate(-12)">
|
|
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
|
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
|
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
|
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
|
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
|
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
|
</g>
|
|
</svg>
|
|
|
|
{/* Plane 2 — small, faster, higher */}
|
|
<svg className="login-plane2" viewBox="0 0 480 120" style={{ position: 'absolute', width: 24, opacity: 0.08 }}>
|
|
<g fill="white" transform="translate(240,60) rotate(-12)">
|
|
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
|
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
|
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
|
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
|
</g>
|
|
</svg>
|
|
|
|
{/* Plane 3 — medium, mid-speed */}
|
|
<svg className="login-plane3" viewBox="0 0 480 120" style={{ position: 'absolute', width: 32, opacity: 0.06 }}>
|
|
<g fill="white" transform="translate(240,60) rotate(-5)">
|
|
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
|
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
|
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
|
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
|
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
|
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
|
</g>
|
|
</svg>
|
|
|
|
{/* Plane 4 — tiny, fast, high */}
|
|
<svg className="login-plane4" viewBox="0 0 480 120" style={{ position: 'absolute', width: 16, opacity: 0.07 }}>
|
|
<g fill="white" transform="translate(240,60) rotate(-10)">
|
|
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
|
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
|
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
|
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
|
</g>
|
|
</svg>
|
|
|
|
{/* Plane 5 — medium, right to left, lower */}
|
|
<svg className="login-plane5" viewBox="0 0 480 120" style={{ position: 'absolute', width: 28, opacity: 0.05 }}>
|
|
<g fill="white" transform="translate(240,60) rotate(8) scale(-1,1)">
|
|
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
|
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
|
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
|
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
|
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
|
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
|
</g>
|
|
</svg>
|
|
|
|
{/* Plane 6 — tiny distant */}
|
|
<svg className="login-plane6" viewBox="0 0 480 120" style={{ position: 'absolute', width: 12, opacity: 0.04 }}>
|
|
<g fill="white" transform="translate(240,60) rotate(-15)">
|
|
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
|
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
|
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
|
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
|
|
|
|
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
|
|
{/* Logo */}
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 48 }}>
|
|
<img src="/logo-light.svg" alt="TREK" style={{ height: 64 }} />
|
|
</div>
|
|
|
|
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 700, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>
|
|
{t('login.tagline')}
|
|
</h2>
|
|
<p style={{ margin: '0 0 44px', fontSize: 15, color: 'rgba(255,255,255,0.5)', lineHeight: 1.7 }}>
|
|
{t('login.description')}
|
|
</p>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
|
{[
|
|
{ Icon: Map, label: t('login.features.maps'), desc: t('login.features.mapsDesc') },
|
|
{ Icon: Zap, label: t('login.features.realtime'), desc: t('login.features.realtimeDesc') },
|
|
{ Icon: Wallet, label: t('login.features.budget'), desc: t('login.features.budgetDesc') },
|
|
{ Icon: Users, label: t('login.features.collab'), desc: t('login.features.collabDesc') },
|
|
{ Icon: CheckSquare, label: t('login.features.packing'), desc: t('login.features.packingDesc') },
|
|
{ Icon: BookMarked, label: t('login.features.bookings'), desc: t('login.features.bookingsDesc') },
|
|
{ Icon: FolderOpen, label: t('login.features.files'), desc: t('login.features.filesDesc') },
|
|
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
|
|
].map(({ Icon, label, desc }) => (
|
|
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'all 0.2s' }}
|
|
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
|
|
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
|
|
<Icon size={17} style={{ color: 'rgba(255,255,255,0.7)', marginBottom: 7 }} />
|
|
<div style={{ fontSize: 12.5, color: 'white', fontWeight: 600, marginBottom: 2 }}>{label}</div>
|
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', lineHeight: 1.4 }}>{desc}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<p style={{ marginTop: 36, fontSize: 11.5, color: 'rgba(255,255,255,0.25)', letterSpacing: '0.03em' }}>
|
|
{t('login.selfHosted')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right — form */}
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 24px', background: '#f9fafb' }}>
|
|
<div style={{ width: '100%', maxWidth: 400 }}>
|
|
|
|
{/* Mobile logo */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, marginBottom: 36 }}
|
|
className="mobile-logo">
|
|
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
|
|
<img src="/logo-dark.svg" alt="TREK" style={{ height: 48 }} />
|
|
<p style={{ margin: 0, fontSize: 16, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
|
|
</div>
|
|
|
|
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
|
{oidcOnly ? (
|
|
<>
|
|
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>{t('login.title')}</h2>
|
|
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{noRedirect ? t('login.oidcLoggedOut') : t('login.oidcOnly')}</p>
|
|
{error && (
|
|
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
<a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
|
|
style={{
|
|
width: '100%', padding: '12px',
|
|
background: '#111827', color: 'white',
|
|
border: 'none', borderRadius: 12,
|
|
fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
|
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
|
textDecoration: 'none', transition: 'all 0.15s',
|
|
boxSizing: 'border-box',
|
|
}}
|
|
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#1f2937' }}
|
|
onMouseLeave={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#111827' }}
|
|
>
|
|
<Shield size={16} />
|
|
{t('login.oidcSignIn', { name: appConfig?.oidc_display_name || 'SSO' })}
|
|
</a>
|
|
</>
|
|
) : (
|
|
<>
|
|
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
|
|
{passwordChangeStep
|
|
? t('login.setNewPassword')
|
|
: mode === 'login' && mfaStep
|
|
? t('login.mfaTitle')
|
|
: mode === 'register'
|
|
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
|
|
: t('login.title')}
|
|
</h2>
|
|
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
|
|
{passwordChangeStep
|
|
? t('login.setNewPasswordHint')
|
|
: mode === 'login' && mfaStep
|
|
? t('login.mfaSubtitle')
|
|
: mode === 'register'
|
|
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
|
|
: t('login.subtitle')}
|
|
</p>
|
|
|
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
{error && (
|
|
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626' }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{passwordChangeStep && (
|
|
<>
|
|
<div style={{ padding: '10px 14px', background: '#fefce8', border: '1px solid #fde68a', borderRadius: 10, fontSize: 13, color: '#92400e' }}>
|
|
{t('settings.mustChangePassword')}
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('settings.newPassword')}</label>
|
|
<div style={{ position: 'relative' }}>
|
|
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
|
<input
|
|
type="password" value={newPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewPassword(e.target.value)} required
|
|
placeholder={t('settings.newPassword')} style={inputBase}
|
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('settings.confirmPassword')}</label>
|
|
<div style={{ position: 'relative' }}>
|
|
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
|
<input
|
|
type="password" value={confirmPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)} required
|
|
placeholder={t('settings.confirmPassword')} style={inputBase}
|
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{mode === 'login' && mfaStep && !passwordChangeStep && (
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
|
|
<div style={{ position: 'relative' }}>
|
|
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
|
<input
|
|
type="text"
|
|
inputMode="text"
|
|
autoComplete="one-time-code"
|
|
value={mfaCode}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
|
placeholder="000000 or XXXX-XXXX"
|
|
required
|
|
style={inputBase}
|
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
|
/>
|
|
</div>
|
|
<p style={{ fontSize: 12, color: '#9ca3af', marginTop: 8 }}>{t('login.mfaHint')}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setMfaStep(false); setMfaToken(''); setMfaCode(''); setError('') }}
|
|
style={{ marginTop: 8, background: 'none', border: 'none', color: '#6b7280', fontSize: 13, cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}
|
|
>
|
|
{t('login.mfaBack')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Username (register only) */}
|
|
{mode === 'register' && !passwordChangeStep && (
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
|
|
<div style={{ position: 'relative' }}>
|
|
<User size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
|
<input
|
|
type="text" value={username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)} required
|
|
placeholder="admin" style={inputBase}
|
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Email */}
|
|
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
|
|
<div style={{ position: 'relative' }}>
|
|
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
|
<input
|
|
type="email" value={email} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} required
|
|
placeholder={t('login.emailPlaceholder')} style={inputBase}
|
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Password */}
|
|
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
|
|
<div style={{ position: 'relative' }}>
|
|
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
|
<input
|
|
type={showPassword ? 'text' : 'password'} value={password} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)} required
|
|
placeholder="••••••••" style={{ ...inputBase, paddingRight: 44 }}
|
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
|
/>
|
|
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
|
|
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
|
|
background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: '#9ca3af',
|
|
}}>
|
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button type="submit" disabled={isLoading} style={{
|
|
marginTop: 4, width: '100%', padding: '12px', background: '#111827', color: 'white',
|
|
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
|
|
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
|
opacity: isLoading ? 0.7 : 1, transition: 'opacity 0.15s',
|
|
}}
|
|
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.background = '#1f2937' }}
|
|
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
|
|
>
|
|
{isLoading
|
|
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
|
|
: <><Plane size={16} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
|
|
}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Toggle login/register */}
|
|
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && !passwordChangeStep && (
|
|
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
|
|
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
|
|
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}
|
|
style={{ background: 'none', border: 'none', color: '#111827', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13 }}>
|
|
{mode === 'login' ? t('login.register') : t('login.signIn')}
|
|
</button>
|
|
</p>
|
|
)}
|
|
</>)}
|
|
</div>
|
|
|
|
{/* OIDC / SSO login button (only when OIDC is configured but not in oidc-only mode) */}
|
|
{appConfig?.oidc_configured && !oidcOnly && (
|
|
<>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
|
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
|
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
|
|
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
|
</div>
|
|
<a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
|
|
style={{
|
|
marginTop: 12, width: '100%', padding: '12px',
|
|
background: 'white', color: '#374151',
|
|
border: '1px solid #d1d5db', borderRadius: 12,
|
|
fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
|
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
|
textDecoration: 'none', transition: 'all 0.15s',
|
|
boxSizing: 'border-box',
|
|
}}
|
|
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
|
|
onMouseLeave={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
|
|
>
|
|
<Shield size={16} />
|
|
{t('login.oidcSignIn', { name: appConfig.oidc_display_name })}
|
|
</a>
|
|
</>
|
|
)}
|
|
|
|
{/* Demo login button */}
|
|
{appConfig?.demo_mode && (
|
|
<button onClick={handleDemoLogin} disabled={isLoading}
|
|
style={{
|
|
marginTop: 16, width: '100%', padding: '14px',
|
|
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
|
|
color: '#451a03', border: 'none', borderRadius: 14,
|
|
fontSize: 15, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
|
|
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
|
opacity: isLoading ? 0.7 : 1, transition: 'all 0.2s',
|
|
boxShadow: '0 2px 12px rgba(245, 158, 11, 0.3)',
|
|
}}
|
|
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
|
|
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
|
|
>
|
|
<Plane size={18} />
|
|
{t('login.demoHint')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<style>{`
|
|
@keyframes spin { to { transform: rotate(360deg) } }
|
|
@keyframes orbFloat1 {
|
|
0%, 100% { top: 15%; left: 30%; }
|
|
25% { top: 25%; left: 55%; }
|
|
50% { top: 45%; left: 40%; }
|
|
75% { top: 20%; left: 20%; }
|
|
}
|
|
@keyframes orbFloat2 {
|
|
0%, 100% { bottom: 20%; right: 15%; }
|
|
25% { bottom: 35%; right: 35%; }
|
|
50% { bottom: 15%; right: 45%; }
|
|
75% { bottom: 40%; right: 20%; }
|
|
}
|
|
.login-orb1 { animation: orbFloat1 20s ease-in-out infinite; }
|
|
.login-orb2 { animation: orbFloat2 25s ease-in-out infinite; }
|
|
|
|
@keyframes twinkle {
|
|
0%, 100% { opacity: 0.15; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
.login-star { animation: twinkle 3s ease-in-out infinite; }
|
|
|
|
@keyframes plane1Move {
|
|
0% { left: -8%; top: 30%; transform: rotate(-8deg); }
|
|
100% { left: 108%; top: 10%; transform: rotate(-12deg); }
|
|
}
|
|
@keyframes plane2Move {
|
|
0% { right: -5%; top: 18%; transform: rotate(5deg); }
|
|
100% { right: 110%; top: 8%; transform: rotate(3deg); }
|
|
}
|
|
@keyframes plane3Move {
|
|
0% { left: -6%; top: 55%; transform: rotate(-10deg); }
|
|
100% { left: 110%; top: 35%; transform: rotate(-6deg); }
|
|
}
|
|
@keyframes plane4Move {
|
|
0% { left: -4%; top: 8%; transform: rotate(-3deg); }
|
|
100% { left: 110%; top: 5%; transform: rotate(-5deg); }
|
|
}
|
|
@keyframes plane5Move {
|
|
0% { right: -6%; top: 65%; transform: rotate(3deg); }
|
|
100% { right: 110%; top: 50%; transform: rotate(-2deg); }
|
|
}
|
|
@keyframes plane6Move {
|
|
0% { left: -3%; top: 75%; transform: rotate(-7deg); }
|
|
100% { left: 110%; top: 58%; transform: rotate(-5deg); }
|
|
}
|
|
.login-plane1 { animation: plane1Move 24s ease-in-out infinite; }
|
|
.login-plane2 { animation: plane2Move 18s ease-in-out infinite; animation-delay: 6s; }
|
|
.login-plane3 { animation: plane3Move 30s ease-in-out infinite; animation-delay: 12s; }
|
|
.login-plane4 { animation: plane4Move 14s ease-in-out infinite; animation-delay: 3s; }
|
|
.login-plane5 { animation: plane5Move 22s ease-in-out infinite; animation-delay: 9s; }
|
|
.login-plane6 { animation: plane6Move 32s ease-in-out infinite; animation-delay: 16s; }
|
|
|
|
`}</style>
|
|
</div>
|
|
)
|
|
}
|