| New file |
| | |
| | | import React, { useState, useEffect, useMemo, useRef } from "react"; |
| | | import { Box, Typography } from "@mui/material"; |
| | | import { useLogin, useNotify } from "react-admin"; |
| | | import { useLocation } from "react-router-dom"; |
| | | import { SPA_NAME, SPA_VERSION } from "@/config/setting"; |
| | | |
| | | const asciiArt = ` |
| | | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
| | | @@@@@@@ RRRRR CCCCC SSSSS @@@@@@@ |
| | | @@@@@@@ RR RR CC CC SS S @@@@@@@ |
| | | @@@@@@@ RRRRR CC SSSSS @@@@@@@ |
| | | @@@@@@@ RR RR CC CC SS @@@@@@@ |
| | | @@@@@@@ RR RR CCCCC SSSSS @@@@@@@ |
| | | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
| | | `; |
| | | |
| | | const promptTexts = { |
| | | username: "Enter login ID: ", |
| | | password: "Enter password: ", |
| | | }; |
| | | |
| | | const LABEL_COLOR = "#ff4cb2"; |
| | | const VALUE_COLOR = "#7dff82"; |
| | | const PROMPT_COLOR = "#46e46e"; |
| | | |
| | | const Login0 = () => { |
| | | const login = useLogin(); |
| | | const notify = useNotify(); |
| | | const location = useLocation(); |
| | | const inputRef = useRef(null); |
| | | const bootTime = useRef(Date.now()); |
| | | |
| | | const [phase, setPhase] = useState("boot"); |
| | | const [inputValue, setInputValue] = useState(""); |
| | | const [history, setHistory] = useState([]); |
| | | const [username, setUsername] = useState(""); |
| | | const [loading, setLoading] = useState(false); |
| | | const [displayedPrompt, setDisplayedPrompt] = useState(""); |
| | | const [uptime, setUptime] = useState("0m 00s"); |
| | | |
| | | const host = typeof window !== "undefined" ? window.location.host : "localhost"; |
| | | const platform = typeof navigator !== "undefined" ? navigator.platform : "RCS OS"; |
| | | const shellName = `${(SPA_NAME || "rcs").toLowerCase()}-shell`; |
| | | |
| | | const bootScript = useMemo(() => { |
| | | const scanTime = new Date().toLocaleString(); |
| | | return [ |
| | | { text: `${host}@mystery-visitor:~$ neofetch`, tone: "command", delay: 300 }, |
| | | { text: "RCS Flow Console ready.", tone: "system", delay: 700 }, |
| | | { text: `Scan timestamp : ${scanTime}`, tone: "system", delay: 700 }, |
| | | { text: "Awaiting credentials ...", tone: "accent", delay: 800 }, |
| | | ]; |
| | | }, [host]); |
| | | |
| | | useEffect(() => { |
| | | const id = setInterval(() => { |
| | | const seconds = Math.floor((Date.now() - bootTime.current) / 1000); |
| | | const minutes = Math.floor(seconds / 60); |
| | | const remain = seconds % 60; |
| | | setUptime(`${minutes}m ${remain.toString().padStart(2, "0")}s`); |
| | | }, 1000); |
| | | return () => clearInterval(id); |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | if (phase !== "boot") { |
| | | return; |
| | | } |
| | | const timers = []; |
| | | let totalDelay = 0; |
| | | bootScript.forEach((line, index) => { |
| | | totalDelay += line.delay ?? 700; |
| | | const timerId = setTimeout(() => { |
| | | setHistory((prev) => [...prev, { text: line.text, tone: line.tone }]); |
| | | if (index === bootScript.length - 1) { |
| | | const doneId = setTimeout(() => setPhase("username"), 400); |
| | | timers.push(doneId); |
| | | } |
| | | }, totalDelay); |
| | | timers.push(timerId); |
| | | }); |
| | | return () => { |
| | | timers.forEach((timer) => clearTimeout(timer)); |
| | | }; |
| | | }, [phase, bootScript]); |
| | | |
| | | const systemInfo = useMemo(() => [ |
| | | { label: "Host", value: host || "localhost" }, |
| | | { label: "OS", value: platform }, |
| | | { label: "Shell", value: shellName }, |
| | | { label: "Version", value: SPA_VERSION || "1.0" }, |
| | | { label: "Theme", value: "homebrew console" }, |
| | | { label: "Uptime", value: uptime }, |
| | | { label: "Instruction", value: "Submit ID then password" }, |
| | | ], [host, platform, shellName, uptime]); |
| | | |
| | | useEffect(() => { |
| | | if (phase !== "username" && phase !== "password") { |
| | | setDisplayedPrompt(""); |
| | | return; |
| | | } |
| | | const text = promptTexts[phase]; |
| | | setDisplayedPrompt(""); |
| | | let index = 0; |
| | | const timer = setInterval(() => { |
| | | setDisplayedPrompt((prev) => prev + text[index]); |
| | | index += 1; |
| | | if (index >= text.length) { |
| | | clearInterval(timer); |
| | | } |
| | | }, 35); |
| | | return () => clearInterval(timer); |
| | | }, [phase]); |
| | | |
| | | useEffect(() => { |
| | | if ((phase === "username" || phase === "password") && !loading) { |
| | | inputRef.current?.focus(); |
| | | } |
| | | }, [phase, loading]); |
| | | |
| | | const pushHistory = (text, tone = "default") => { |
| | | setHistory((prev) => [...prev, { text, tone }]); |
| | | }; |
| | | |
| | | const resetToUsername = (message) => { |
| | | if (message) { |
| | | pushHistory(message, "warning"); |
| | | } |
| | | setPhase("username"); |
| | | setUsername(""); |
| | | setInputValue(""); |
| | | }; |
| | | |
| | | const handleLogin = (payload) => { |
| | | setLoading(true); |
| | | setPhase("submitting"); |
| | | pushHistory(">> verifying credentials ...", "system"); |
| | | |
| | | login( |
| | | payload, |
| | | location.state ? location.state.nextPathname : "/" |
| | | ) |
| | | .then(() => { |
| | | pushHistory(">> access granted. redirecting ...", "accent"); |
| | | }) |
| | | .catch((res) => { |
| | | const { code, msg } = res || {}; |
| | | const message = msg || "Unknown error"; |
| | | if (code === 10003 || code === 10004) { |
| | | resetToUsername(`Error(${code}): ${message}`); |
| | | } else if (code === 10001) { |
| | | setPhase("password"); |
| | | setInputValue(""); |
| | | pushHistory(`Error(${code}): ${message}`, "error"); |
| | | } else { |
| | | pushHistory(`Error: ${message}`, "error"); |
| | | setPhase("password"); |
| | | setInputValue(""); |
| | | } |
| | | notify(message, { type: "error", messageArgs: { _: message } }); |
| | | }) |
| | | .finally(() => { |
| | | setLoading(false); |
| | | }); |
| | | }; |
| | | |
| | | const handleAdvance = () => { |
| | | if (loading) { |
| | | return; |
| | | } |
| | | const trimmed = inputValue.trim(); |
| | | if (!trimmed) { |
| | | pushHistory("Warning: input cannot be empty.", "warning"); |
| | | return; |
| | | } |
| | | |
| | | if (phase === "username") { |
| | | setUsername(trimmed); |
| | | pushHistory(`${promptTexts.username}${trimmed}`, "command"); |
| | | setPhase("password"); |
| | | setInputValue(""); |
| | | return; |
| | | } |
| | | |
| | | if (phase === "password") { |
| | | pushHistory(`${promptTexts.password}${"*".repeat(trimmed.length)}`, "command"); |
| | | setInputValue(""); |
| | | handleLogin({ username, password: trimmed }); |
| | | } |
| | | }; |
| | | |
| | | const renderPrompt = phase === "username" || phase === "password"; |
| | | |
| | | const topLine = `${host}@mystery-visitor:~$ neofetch`; |
| | | const bottomLine = `${host}@mystery-visitor:~$ type "help" if you are lost ...`; |
| | | const brandTop = (SPA_NAME || "RCS").toUpperCase(); |
| | | const brandBottom = "FLOW"; |
| | | |
| | | return ( |
| | | <Box |
| | | sx={{ |
| | | minHeight: "100vh", |
| | | backgroundColor: "#030203", |
| | | color: "#aee6b8", |
| | | fontFamily: `'OCR A Std', 'Share Tech Mono', 'Courier New', monospace`, |
| | | fontSize: { xs: 11, md: 13 }, |
| | | letterSpacing: "0.04em", |
| | | display: "flex", |
| | | flexDirection: "column", |
| | | alignItems: "center", |
| | | justifyContent: "space-between", |
| | | padding: { xs: 2, md: 4 }, |
| | | gap: 3, |
| | | }} |
| | | > |
| | | <Typography sx={{ color: PROMPT_COLOR, width: "100%", textAlign: "left" }}> |
| | | {topLine} |
| | | </Typography> |
| | | |
| | | <Box |
| | | sx={{ |
| | | width: "100%", |
| | | display: "flex", |
| | | flexDirection: { xs: "column", md: "row" }, |
| | | gap: { xs: 3, md: 8 }, |
| | | }} |
| | | > |
| | | <Box sx={{ flex: 1, minWidth: 0 }}> |
| | | <Typography |
| | | component="pre" |
| | | sx={{ |
| | | color: "#ff3f9c", |
| | | m: 0, |
| | | whiteSpace: "pre", |
| | | fontSize: { xs: 10, md: 14 }, |
| | | lineHeight: 1.05, |
| | | }} |
| | | > |
| | | {asciiArt} |
| | | </Typography> |
| | | </Box> |
| | | |
| | | <Box |
| | | sx={{ |
| | | flex: 1, |
| | | display: "flex", |
| | | flexDirection: "column", |
| | | gap: 2, |
| | | minWidth: 0, |
| | | }} |
| | | > |
| | | <Box> |
| | | <Typography |
| | | sx={{ |
| | | fontSize: { xs: 26, md: 44 }, |
| | | letterSpacing: { xs: "0.4em", md: "0.6em" }, |
| | | color: "#6ef68c", |
| | | textTransform: "uppercase", |
| | | textShadow: "0 0 6px rgba(110,246,140,0.7)", |
| | | }} |
| | | > |
| | | {brandTop} |
| | | </Typography> |
| | | <Typography |
| | | sx={{ |
| | | fontSize: { xs: 24, md: 40 }, |
| | | letterSpacing: { xs: "0.35em", md: "0.55em" }, |
| | | color: "#6ef68c", |
| | | textTransform: "uppercase", |
| | | textShadow: "0 0 6px rgba(110,246,140,0.7)", |
| | | }} |
| | | > |
| | | {brandBottom} |
| | | </Typography> |
| | | </Box> |
| | | |
| | | <Box |
| | | component="ul" |
| | | sx={{ |
| | | listStyle: "none", |
| | | m: 0, |
| | | p: 0, |
| | | display: "flex", |
| | | flexDirection: "column", |
| | | gap: 0.3, |
| | | }} |
| | | > |
| | | {systemInfo.map((item) => ( |
| | | <li key={item.label} style={{ display: "flex", gap: "12px" }}> |
| | | <Typography component="span" sx={{ minWidth: 120, color: LABEL_COLOR }}> |
| | | {item.label}: |
| | | </Typography> |
| | | <Typography component="span" sx={{ color: VALUE_COLOR }}> |
| | | {item.value} |
| | | </Typography> |
| | | </li> |
| | | ))} |
| | | </Box> |
| | | </Box> |
| | | </Box> |
| | | |
| | | <Box |
| | | component="form" |
| | | onSubmit={(event) => { |
| | | event.preventDefault(); |
| | | handleAdvance(); |
| | | }} |
| | | sx={{ |
| | | width: "100%", |
| | | display: "flex", |
| | | flexDirection: "column", |
| | | gap: 1, |
| | | mt: { xs: 2, md: 3 }, |
| | | maxWidth: 960, |
| | | }} |
| | | > |
| | | <Typography sx={{ color: "#f1f1f1", fontSize: { xs: 12, md: 13 } }}> |
| | | ACCESS LOG |
| | | </Typography> |
| | | <Box |
| | | sx={{ |
| | | minHeight: 160, |
| | | maxHeight: 220, |
| | | overflowY: "auto", |
| | | color: "#c5e6c5", |
| | | display: "flex", |
| | | flexDirection: "column", |
| | | gap: 0.2, |
| | | fontSize: { xs: 11, md: 13 }, |
| | | }} |
| | | > |
| | | {history.map((line, index) => ( |
| | | <Typography |
| | | key={`${line.tone}-${index}`} |
| | | sx={{ |
| | | color: |
| | | line.tone === "accent" |
| | | ? "#73ee73" |
| | | : line.tone === "system" |
| | | ? "#58c8ff" |
| | | : line.tone === "command" |
| | | ? "#ffffff" |
| | | : line.tone === "warning" |
| | | ? "#e6cd67" |
| | | : line.tone === "error" |
| | | ? "#ff7a7a" |
| | | : "#b8d9b8", |
| | | }} |
| | | > |
| | | {line.text} |
| | | </Typography> |
| | | ))} |
| | | </Box> |
| | | |
| | | {renderPrompt && ( |
| | | <Box sx={{ display: "flex", alignItems: "center", color: "#fff" }}> |
| | | <Typography component="span" sx={{ color: PROMPT_COLOR }}> |
| | | {displayedPrompt} |
| | | </Typography> |
| | | <Box |
| | | component="input" |
| | | ref={inputRef} |
| | | value={inputValue} |
| | | onChange={(event) => setInputValue(event.target.value)} |
| | | disabled={loading} |
| | | type={phase === "password" ? "password" : "text"} |
| | | autoComplete="off" |
| | | sx={{ |
| | | flex: 1, |
| | | border: "none", |
| | | outline: "none", |
| | | background: "transparent", |
| | | color: "#ffffff", |
| | | fontFamily: `'OCR A Std', 'Courier New', monospace`, |
| | | fontSize: { xs: 12, md: 13 }, |
| | | ml: 1, |
| | | }} |
| | | /> |
| | | </Box> |
| | | )} |
| | | |
| | | {phase === "submitting" && ( |
| | | <Typography sx={{ color: "#73ee73" }}> CONNECTING ...</Typography> |
| | | )} |
| | | </Box> |
| | | |
| | | <Typography sx={{ color: PROMPT_COLOR, width: "100%", textAlign: "left" }}> |
| | | {bottomLine} |
| | | </Typography> |
| | | </Box> |
| | | ); |
| | | }; |
| | | |
| | | export default Login0; |