/ frontend / src / app / views / sessions / login / JwtLogin.jsx
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  }