JwtLogin.jsx
1 import { useState, useEffect } from "react"; 2 import { useNavigate } from "react-router-dom"; 3 import { useTranslation } from "react-i18next"; 4 import { 5 Box, TextField, Button, Typography, Alert, Collapse, 6 Checkbox, FormControlLabel, styled, keyframes, 7 } from "@mui/material"; 8 import { LoadingButton } from "@mui/lab"; 9 import GitHubIcon from "@mui/icons-material/GitHub"; 10 import GoogleIcon from "@mui/icons-material/Google"; 11 import VpnKeyIcon from "@mui/icons-material/VpnKey"; 12 import LockIcon from "@mui/icons-material/Lock"; 13 import useAuth from "app/hooks/useAuth"; 14 import { usePlatformCapabilities } from "app/contexts/PlatformContext"; 15 16 // --- Animations --- 17 const aurora = keyframes` 18 0% { background-position: 0% 50%; transform: rotate(0deg); } 19 25% { background-position: 50% 100%; } 20 50% { background-position: 100% 50%; transform: rotate(1deg); } 21 75% { background-position: 50% 0%; } 22 100% { background-position: 0% 50%; transform: rotate(0deg); } 23 `; 24 25 const float1 = keyframes` 26 0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.7; } 27 25% { transform: translate(40px, -30px) scale(1.1); opacity: 0.9; } 28 50% { transform: translate(-10px, 20px) scale(0.95); opacity: 0.6; } 29 75% { transform: translate(20px, -10px) scale(1.05); opacity: 0.8; } 30 `; 31 32 const float2 = keyframes` 33 0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.6; } 34 30% { transform: translate(-30px, 25px) scale(0.9); opacity: 0.8; } 35 60% { transform: translate(25px, -35px) scale(1.12); opacity: 0.5; } 36 `; 37 38 const float3 = keyframes` 39 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 0.5; } 40 40% { transform: translate(15px, 30px) scale(1.08) rotate(2deg); opacity: 0.7; } 41 80% { transform: translate(-20px, -15px) scale(0.92) rotate(-1deg); opacity: 0.4; } 42 `; 43 44 const fadeSlideUp = keyframes` 45 from { opacity: 0; transform: translateY(24px) scale(0.97); } 46 to { opacity: 1; transform: translateY(0) scale(1); } 47 `; 48 49 const logoPulse = keyframes` 50 0%, 100% { filter: drop-shadow(0 0 20px rgba(56, 139, 253, 0.2)) drop-shadow(0 8px 24px rgba(0,0,0,0.3)); } 51 50% { filter: drop-shadow(0 0 40px rgba(56, 139, 253, 0.35)) drop-shadow(0 8px 24px rgba(0,0,0,0.3)); } 52 `; 53 54 const shimmer = keyframes` 55 0% { background-position: -200% center; } 56 100% { background-position: 200% center; } 57 `; 58 59 const shake = keyframes` 60 0%, 100% { transform: translateX(0); } 61 15% { transform: translateX(-8px) rotate(-0.5deg); } 62 30% { transform: translateX(6px) rotate(0.3deg); } 63 45% { transform: translateX(-4px) rotate(-0.2deg); } 64 60% { transform: translateX(2px); } 65 `; 66 67 const lineGlow = keyframes` 68 0% { opacity: 0; width: 0; } 69 50% { opacity: 1; width: 60px; } 70 100% { opacity: 0; width: 0; } 71 `; 72 73 // --- Styled Components --- 74 const Root = styled("div")({ 75 minHeight: "100vh", 76 display: "flex", 77 alignItems: "center", 78 justifyContent: "center", 79 position: "relative", 80 overflow: "hidden", 81 background: "#060613", 82 "&::before": { 83 content: '""', 84 position: "absolute", 85 inset: 0, 86 background: "radial-gradient(ellipse 120% 80% at 50% 120%, rgba(56, 139, 253, 0.08) 0%, transparent 60%), radial-gradient(ellipse 100% 60% at 20% 0%, rgba(6, 182, 212, 0.05) 0%, transparent 50%), radial-gradient(ellipse 80% 60% at 80% 20%, rgba(30, 100, 200, 0.06) 0%, transparent 50%)", 87 pointerEvents: "none", 88 }, 89 }); 90 91 const GridOverlay = styled("div")({ 92 position: "absolute", 93 inset: 0, 94 backgroundImage: `linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px)`, 95 backgroundSize: "60px 60px", 96 maskImage: "radial-gradient(ellipse 70% 70% at 50% 50%, black 20%, transparent 70%)", 97 pointerEvents: "none", 98 }); 99 100 const Orb = styled("div")(({ color, size, top, left, anim }) => ({ 101 position: "absolute", 102 width: size || 400, 103 height: size || 400, 104 borderRadius: "50%", 105 background: color || "rgba(56, 139, 253, 0.08)", 106 filter: "blur(100px)", 107 top: top || "20%", 108 left: left || "10%", 109 animation: `${anim === 3 ? float3 : anim === 2 ? float2 : float1} ${anim === 3 ? "18s" : anim === 2 ? "14s" : "11s"} ease-in-out infinite`, 110 pointerEvents: "none", 111 })); 112 113 const GlassCard = styled(Box)(({ shaking }) => ({ 114 position: "relative", 115 zIndex: 2, 116 width: "100%", 117 maxWidth: 440, 118 margin: "1rem", 119 padding: "3rem 2.5rem 2.5rem", 120 borderRadius: 24, 121 background: "linear-gradient(145deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%)", 122 backdropFilter: "blur(40px) saturate(1.5)", 123 border: "1px solid rgba(255, 255, 255, 0.07)", 124 boxShadow: ` 125 0 32px 100px rgba(0, 0, 0, 0.5), 126 0 0 60px rgba(56, 139, 253, 0.04), 127 inset 0 1px 0 rgba(255, 255, 255, 0.06), 128 inset 0 -1px 0 rgba(0, 0, 0, 0.1) 129 `, 130 animation: shaking 131 ? `${shake} 0.4s ease` 132 : `${fadeSlideUp} 0.7s cubic-bezier(0.16, 1, 0.3, 1)`, 133 color: "#e2e8f0", 134 "&::before": { 135 content: '""', 136 position: "absolute", 137 top: 0, 138 left: "50%", 139 transform: "translateX(-50%)", 140 width: 80, 141 height: 2, 142 borderRadius: 2, 143 background: "linear-gradient(90deg, transparent, rgba(56, 139, 253, 0.6), transparent)", 144 animation: `${lineGlow} 3s ease-in-out infinite`, 145 }, 146 })); 147 148 const StyledInput = styled(TextField)({ 149 "& .MuiOutlinedInput-root": { 150 borderRadius: 14, 151 backgroundColor: "rgba(255, 255, 255, 0.03)", 152 color: "#e2e8f0", 153 fontSize: "0.95rem", 154 transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", 155 "& fieldset": { 156 borderColor: "rgba(255, 255, 255, 0.08)", 157 transition: "all 0.3s ease", 158 }, 159 "&:hover fieldset": { 160 borderColor: "rgba(56, 139, 253, 0.35)", 161 }, 162 "&.Mui-focused": { 163 backgroundColor: "rgba(56, 139, 253, 0.04)", 164 }, 165 "&.Mui-focused fieldset": { 166 borderColor: "#388bfd", 167 boxShadow: "0 0 0 4px rgba(56, 139, 253, 0.1), 0 0 20px rgba(56, 139, 253, 0.05)", 168 }, 169 }, 170 "& .MuiInputLabel-root": { 171 color: "rgba(255, 255, 255, 0.35)", 172 "&.Mui-focused": { color: "#79c0ff" }, 173 }, 174 }); 175 176 const PrimaryButton = styled(LoadingButton)({ 177 borderRadius: 14, 178 padding: "13px 0", 179 fontSize: "0.95rem", 180 fontWeight: 600, 181 textTransform: "none", 182 letterSpacing: "0.02em", 183 background: "linear-gradient(135deg, #388bfd 0%, #58a6ff 50%, #388bfd 100%)", 184 backgroundSize: "200% auto", 185 boxShadow: "0 4px 20px rgba(56, 139, 253, 0.25), inset 0 1px 0 rgba(255,255,255,0.15)", 186 transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", 187 "&:hover": { 188 backgroundPosition: "right center", 189 boxShadow: "0 8px 32px rgba(56, 139, 253, 0.4), inset 0 1px 0 rgba(255,255,255,0.15)", 190 transform: "translateY(-1px)", 191 }, 192 "&:active": { 193 transform: "translateY(0)", 194 }, 195 "&.Mui-disabled": { 196 background: "rgba(56, 139, 253, 0.2)", 197 boxShadow: "none", 198 }, 199 }); 200 201 const SSOButton = styled(Button)({ 202 borderRadius: 14, 203 padding: "11px 16px", 204 fontSize: "0.88rem", 205 fontWeight: 500, 206 textTransform: "none", 207 color: "rgba(255,255,255,0.7)", 208 borderColor: "rgba(255, 255, 255, 0.07)", 209 backgroundColor: "rgba(255, 255, 255, 0.02)", 210 backdropFilter: "blur(8px)", 211 transition: "all 0.25s ease", 212 "&:hover": { 213 borderColor: "rgba(56, 139, 253, 0.4)", 214 backgroundColor: "rgba(56, 139, 253, 0.06)", 215 color: "#a5d6ff", 216 transform: "translateY(-1px)", 217 boxShadow: "0 4px 12px rgba(0,0,0,0.2)", 218 }, 219 }); 220 221 const OrDivider = styled(Box)({ 222 display: "flex", 223 alignItems: "center", 224 gap: 16, 225 margin: "24px 0", 226 "&::before, &::after": { 227 content: '""', 228 flex: 1, 229 height: 1, 230 background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent)", 231 }, 232 }); 233 234 const SSO_ICON_MAP = { 235 google: <GoogleIcon sx={{ fontSize: 18 }} />, 236 github: <GitHubIcon sx={{ fontSize: 18 }} />, 237 microsoft: ( 238 <Box 239 component="img" 240 src="/admin/assets/images/microsoft-icon.svg" 241 alt="" 242 sx={{ width: 18, height: 18, filter: "brightness(1.5)" }} 243 onError={(e) => { e.target.style.display = "none"; }} 244 /> 245 ), 246 oidc: <VpnKeyIcon sx={{ fontSize: 18 }} />, 247 }; 248 249 export default function JwtLogin() { 250 const { t } = useTranslation(); 251 const [type, setType] = useState(null); 252 const navigate = useNavigate(); 253 const [loading, setLoading] = useState(false); 254 const [state, setState] = useState({}); 255 const [error, setError] = useState(""); 256 const [shaking, setShaking] = useState(false); 257 const [totpToken, setTotpToken] = useState(null); 258 const [totpCode, setTotpCode] = useState(""); 259 const [useRecovery, setUseRecovery] = useState(false); 260 const { platformCapabilities } = usePlatformCapabilities(); 261 const ssoProviders = platformCapabilities?.sso || []; 262 const ssoProviderNames = platformCapabilities?.sso_provider_names || {}; 263 const authDisableLocal = platformCapabilities?.auth_disable_local || false; 264 265 const { login, verifyTotp } = useAuth(); 266 267 useEffect(() => { 268 try { 269 if (sessionStorage.getItem("session_expired") === "1") { 270 setError("Your session has expired. Please log in again."); 271 sessionStorage.removeItem("session_expired"); 272 } 273 } catch {} 274 }, []); 275 276 const triggerError = (msg) => { 277 setError(msg); 278 setShaking(true); 279 setTimeout(() => setShaking(false), 500); 280 }; 281 282 const handleSubmit = async (event) => { 283 event.preventDefault(); 284 setError(""); 285 if (type === null) { setType("password"); return; } 286 if (type === "password") { 287 setLoading(true); 288 try { 289 const result = await login(state.email, state.password); 290 if (result && result.requires_totp) { 291 setTotpToken(result.totp_token); 292 setType("totp"); 293 setLoading(false); 294 } else { 295 window.location.href = "/admin"; 296 } 297 } catch (e) { 298 triggerError(e.message || "Login failed. Please check your credentials."); 299 setLoading(false); 300 } 301 } 302 }; 303 304 const handleTotpSubmit = async (event) => { 305 event.preventDefault(); 306 setError(""); 307 setLoading(true); 308 try { 309 await verifyTotp(totpToken, totpCode); 310 window.location.href = "/admin"; 311 } catch (e) { 312 triggerError(e.message || "Invalid code."); 313 setLoading(false); 314 } 315 }; 316 317 const handleChange = (event) => { 318 if (event && event.persist) event.persist(); 319 setError(""); 320 setState({ ...state, [event.target.name]: event.target.type === "checkbox" ? event.target.checked : event.target.value }); 321 }; 322 323 const handleSSOLogin = (provider) => () => { 324 window.location.href = `/oauth/${provider}/login`; 325 }; 326 327 const appName = platformCapabilities?.app_name || process.env.REACT_APP_RESTAI_NAME || "RESTai"; 328 329 return ( 330 <Root> 331 {/* Subtle grid overlay */} 332 <GridOverlay /> 333 334 {/* Floating orbs — deeper, more cinematic */} 335 <Orb color="rgba(56, 139, 253, 0.06)" size={600} top="-15%" left="-10%" anim={1} /> 336 <Orb color="rgba(30, 100, 200, 0.05)" size={500} top="55%" left="65%" anim={2} /> 337 <Orb color="rgba(6, 182, 212, 0.03)" size={450} top="20%" left="40%" anim={3} /> 338 <Orb color="rgba(14, 165, 233, 0.025)" size={350} top="70%" left="15%" anim={1} /> 339 340 <GlassCard shaking={shaking}> 341 {/* Logo */} 342 <Box sx={{ textAlign: "center", mb: 3 }}> 343 <Box 344 component="img" 345 src="/admin/assets/images/restai-logo.png" 346 alt={appName} 347 sx={{ 348 width: 180, 349 height: 180, 350 mb: 0.5, 351 animation: `${logoPulse} 4s ease-in-out infinite`, 352 }} 353 /> 354 <Typography 355 sx={{ 356 fontSize: "1.8rem", 357 fontWeight: 800, 358 letterSpacing: "-0.03em", 359 background: `linear-gradient(90deg, #e2e8f0 0%, #58a6ff 40%, #79c0ff 60%, #e2e8f0 100%)`, 360 backgroundSize: "200% auto", 361 animation: `${shimmer} 4s linear infinite`, 362 WebkitBackgroundClip: "text", 363 WebkitTextFillColor: "transparent", 364 }} 365 > 366 {appName} 367 </Typography> 368 </Box> 369 370 {/* Error */} 371 <Collapse in={!!error}> 372 <Alert 373 severity="error" 374 onClose={() => setError("")} 375 sx={{ 376 mb: 2.5, 377 borderRadius: 3, 378 backgroundColor: "rgba(239, 68, 68, 0.08)", 379 border: "1px solid rgba(239, 68, 68, 0.15)", 380 color: "#fca5a5", 381 backdropFilter: "blur(8px)", 382 "& .MuiAlert-icon": { color: "#f87171" }, 383 "& .MuiAlert-action .MuiIconButton-root": { color: "#fca5a5" }, 384 }} 385 > 386 {error} 387 </Alert> 388 </Collapse> 389 390 {/* TOTP */} 391 {type === "totp" ? ( 392 <form onSubmit={handleTotpSubmit}> 393 <Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1.5 }}> 394 <LockIcon sx={{ color: "#79c0ff", fontSize: 20 }} /> 395 <Typography sx={{ fontWeight: 600, fontSize: "1rem" }}>{t("sessions.totpTitle")}</Typography> 396 </Box> 397 <Typography sx={{ fontSize: "0.85rem", color: "rgba(255,255,255,0.4)", mb: 2.5 }}> 398 {useRecovery ? t("sessions.totpRecoveryLabel") : t("sessions.totpSubtitle")} 399 </Typography> 400 <StyledInput 401 fullWidth autoFocus 402 label={useRecovery ? t("sessions.totpRecoveryLabel") : t("sessions.totpCodeLabel")} 403 value={totpCode} 404 onChange={(e) => { setTotpCode(e.target.value); setError(""); }} 405 placeholder={useRecovery ? "abcd1234" : "123456"} 406 inputProps={{ maxLength: useRecovery ? 20 : 6, autoComplete: "one-time-code" }} 407 sx={{ mb: 1.5 }} 408 /> 409 <Button 410 size="small" 411 onClick={() => { setUseRecovery(!useRecovery); setTotpCode(""); }} 412 sx={{ mb: 2, textTransform: "none", color: "#79c0ff", fontSize: "0.8rem", "&:hover": { backgroundColor: "rgba(99,102,241,0.06)" } }} 413 > 414 {useRecovery ? t("sessions.totpCodeLabel") : t("sessions.totpRecoveryToggle")} 415 </Button> 416 <PrimaryButton type="submit" loading={loading} variant="contained" fullWidth>{t("sessions.totpVerify")}</PrimaryButton> 417 <Button 418 fullWidth size="small" 419 onClick={() => { setType(null); setTotpToken(null); setTotpCode(""); setLoading(false); setError(""); }} 420 sx={{ mt: 1.5, textTransform: "none", color: "rgba(255,255,255,0.3)", fontSize: "0.8rem", "&:hover": { color: "rgba(255,255,255,0.6)" } }} 421 > 422 {t("common.back")} 423 </Button> 424 </form> 425 ) : ( 426 <> 427 {/* Local Login */} 428 {!authDisableLocal && ( 429 <form onSubmit={handleSubmit}> 430 <StyledInput fullWidth autoFocus name="email" label={t("sessions.username")} onChange={handleChange} sx={{ mb: 2 }} /> 431 <Collapse in={type === "password"}> 432 <StyledInput fullWidth name="password" type="password" label={t("sessions.password")} onChange={handleChange} sx={{ mb: 2 }} /> 433 </Collapse> 434 <Box sx={{ display: "flex", alignItems: "center", mb: 2.5 }}> 435 <FormControlLabel 436 control={ 437 <Checkbox size="small" name="remember" onChange={handleChange} checked={state.remember || false} 438 sx={{ color: "rgba(255,255,255,0.15)", "&.Mui-checked": { color: "#79c0ff" } }} 439 /> 440 } 441 label={<Typography sx={{ fontSize: "0.82rem", color: "rgba(255,255,255,0.35)" }}>{t("sessions.rememberMe")}</Typography>} 442 /> 443 </Box> 444 <PrimaryButton type="submit" loading={loading} variant="contained" fullWidth> 445 {type === null ? t("common.next") : t("sessions.signInAction")} 446 </PrimaryButton> 447 </form> 448 )} 449 450 {/* SSO */} 451 {ssoProviders.length > 0 && ( 452 <> 453 {!authDisableLocal && ( 454 <OrDivider> 455 <Typography sx={{ fontSize: "0.7rem", color: "rgba(255,255,255,0.2)", letterSpacing: "0.15em", textTransform: "uppercase", fontWeight: 500 }}> 456 {t("sessions.or")} 457 </Typography> 458 </OrDivider> 459 )} 460 <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> 461 {ssoProviders.map((provider) => ( 462 <SSOButton 463 key={provider} variant="outlined" fullWidth 464 onClick={handleSSOLogin(provider)} 465 startIcon={SSO_ICON_MAP[provider] || <VpnKeyIcon sx={{ fontSize: 18 }} />} 466 > 467 {t("sessions.signInWith", { provider: ssoProviderNames[provider] || provider.charAt(0).toUpperCase() + provider.slice(1) })} 468 </SSOButton> 469 ))} 470 </Box> 471 </> 472 )} 473 </> 474 )} 475 </GlassCard> 476 </Root> 477 ); 478 }