/* blueyetutor — /login (+ /verify, /reset, /forgot anonymous routes) * * Modes (driven by route + local state): * route="login" → tab "login" or "signup" (auth form) * route="verify" → consume ?token=… ; show verifying / success / failure * route="reset" → consume ?token=… ; new-password form * route="forgot" → email input → send reset link * * Sign-up flow (when SMTP is configured): * submit → server returns verification_required=true (no cookie set) * UI switches to a "메일 확인하세요" screen with a resend button. * User clicks link in their inbox → #/verify?token=… → auto-login. * * Backend (api/auth.py): * POST /api/auth/login { email, password } * POST /api/auth/signup { email, password, role } * POST /api/auth/resend-verification { email } * GET /api/auth/verify?token=… * POST /api/auth/forgot-password { email } * POST /api/auth/reset-password { token, new_password } */ const { useState: loginUseState, useEffect: loginUseEffect } = React; function LoginPage({ route }) { // tab 은 가입/로그인 toggle. mode 는 화면 흐름. const initialTab = route === "signup" ? "signup" : "login"; const [tab, setTab] = loginUseState(initialTab); const [mode, setMode] = loginUseState( route === "verify" ? "verifying" : route === "reset" ? "reset" : route === "forgot" ? "forgot" : "auth" ); return (
Blueye
Blueyetutor
선생님,

가르치는 일에만 집중하세요

자료 정리, 문제집 제작, 오답노트 작성, 학생 관리 — 저희가 다 해드립니다.

AI 및 자체 엔진 기반 시스템으로 선생님의 시간을 아껴드립니다.

{mode === "verifying" && } {mode === "reset" && setMode("auth")} />} {mode === "forgot" && setMode("auth")} />} {mode === "auth" && setMode("forgot")} onPending={() => setMode("pending-verify")} />} {mode === "pending-verify" && setMode("auth")} />}
); } // ---- core auth (login + signup tab) ------------------------------------ function AuthForm({ tab, setTab, onForgot, onPending }) { const [role, setRole] = loginUseState("teacher"); const [email, setEmail] = loginUseState(""); const [pw, setPw] = loginUseState(""); const [pw2, setPw2] = loginUseState(""); const [busy, setBusy] = loginUseState(false); const [err, setErr] = loginUseState(""); const [notVerified, setNotVerified] = loginUseState(null); // email if 403 not_verified const validEmail = /\S+@\S+\.\S+/.test(email); const submit = async e => { e.preventDefault(); setErr(""); setNotVerified(null); if (!validEmail) { setErr("이메일 형식이 올바르지 않습니다."); return; } if (tab === "signup") { if (pw.length < 8) { setErr("비밀번호는 최소 8자 이상이어야 합니다."); return; } if (pw !== pw2) { setErr("비밀번호가 일치하지 않습니다."); return; } } else { if (pw.length < 1) { setErr("비밀번호를 입력하세요."); return; } } setBusy(true); const cleanEmail = email.trim().toLowerCase(); const res = tab === "signup" ? await window.storeActions.signup(cleanEmail, pw, role) : await window.storeActions.login(cleanEmail, pw); setBusy(false); if (!res || !res.ok) { if (res && res.error === "not_verified") { setNotVerified(cleanEmail); return; } setErr((res && res.message) || "로그인에 실패했습니다. 다시 시도해 주세요."); return; } // signup flow: 메일 인증 필요면 pending 화면, 아니면 store.user 셋팅 후 자동 redirect. if (tab === "signup" && res.verification_required) { window.__pendingEmail = cleanEmail; onPending(); } }; const resend = async () => { if (!notVerified) return; await window.storeActions.resendVerification(notVerified); setErr("인증 메일을 다시 보냈어요. 받은편지함을 확인하세요."); }; return ( <>

{tab === "signup" ? "가입하기" : "다시 만나서 반가워요"}

학원 계정으로 계속 진행합니다.

{ setTab(v); setErr(""); setNotVerified(null); }} />
{tab === "signup" && ( )} setEmail(e.target.value)} placeholder="you@academy.com" /> setPw(e.target.value)} placeholder="••••••••" /> {tab === "signup" && ( setPw2(e.target.value)} placeholder="••••••••" /> )} {err && {err}} {notVerified && ( 받은편지함의 인증 링크를 클릭한 뒤 다시 로그인하세요.  { e.preventDefault(); resend(); }}> 인증 메일 재발송 )} {tab === "login" && (
{ e.preventDefault(); onForgot(); }}> 비밀번호를 잊으셨나요?
)}
또는
가입은 운영자 allowlist 등록 후 가능합니다.
); } // ---- mode: "pending-verify" — sent the mail, waiting on the user's click function PendingVerify({ onBack }) { const email = window.__pendingEmail || ""; const [msg, setMsg] = loginUseState(null); const [busy, setBusy] = loginUseState(false); const resend = async () => { if (!email) return; setBusy(true); const r = await window.storeActions.resendVerification(email); setBusy(false); setMsg(r && r.ok ? { ok: true, text: "다시 보냈습니다. 받은편지함 / 스팸함 확인하세요." } : { ok: false, text: (r && r.message) || "재발송 실패" }); }; return ( <>

메일 확인하세요

{email} 으로 인증 링크를 보냈습니다. 링크를 클릭하면 가입이 완료됩니다.

링크가 안 오면 스팸함 / 프로모션 폴더를 확인하세요. 24시간 안에 클릭해야 합니다.
{msg && {msg.text}} ); } // ---- mode: "verifying" — eat the ?token=… on page load and route into the app function VerifyConsumer() { const [state, setState] = loginUseState("checking"); // checking | ok | fail const [msg, setMsg] = loginUseState(""); loginUseEffect(() => { const tok = (typeof hashQueryParam === "function" && hashQueryParam("token")) || ""; if (!tok) { setState("fail"); setMsg("인증 토큰이 없습니다."); return; } (async () => { const r = await window.storeActions.verifyEmailToken(tok); if (r && r.ok) { setState("ok"); // 잠시 보여주고 home 으로 setTimeout(() => { location.hash = "#/upload"; }, 1200); } else { setState("fail"); setMsg((r && r.message) || "인증에 실패했습니다."); } })(); }, []); return ( <>

이메일 인증

{state === "checking" && 인증 확인 중…} {state === "ok" && 인증 완료! 잠시 후 화면이 이동합니다.} {state === "fail" && <> {msg} } ); } // ---- mode: "forgot" — email input + send reset link function ForgotForm({ onBack }) { const [email, setEmail] = loginUseState(""); const [busy, setBusy] = loginUseState(false); const [msg, setMsg] = loginUseState(null); const validEmail = /\S+@\S+\.\S+/.test(email); const submit = async e => { e.preventDefault(); if (!validEmail) { setMsg({ ok: false, text: "이메일 형식이 올바르지 않습니다." }); return; } setBusy(true); setMsg(null); const r = await window.storeActions.forgotPassword(email.trim().toLowerCase()); setBusy(false); if (r && r.ok) { setMsg({ ok: true, text: r.smtp_configured ? "재설정 메일을 보냈습니다. 받은편지함 / 스팸함을 확인하세요. (1시간 유효)" : "메일 서버가 설정되지 않았습니다. 운영자에게 문의하세요." }); } else { setMsg({ ok: false, text: (r && r.message) || "요청 실패" }); } }; return (

비밀번호 재설정

가입한 이메일로 재설정 링크를 보내드려요.

setEmail(e.target.value)} placeholder="you@academy.com" /> {msg && {msg.text}}
); } // ---- mode: "reset" — got here via email link → set a new password function ResetForm({ onDone }) { const [pw, setPw] = loginUseState(""); const [pw2, setPw2] = loginUseState(""); const [busy, setBusy] = loginUseState(false); const [msg, setMsg] = loginUseState(null); const tok = (typeof hashQueryParam === "function" && hashQueryParam("token")) || ""; const submit = async e => { e.preventDefault(); if (!tok) { setMsg({ ok: false, text: "재설정 토큰이 없습니다." }); return; } if (pw.length < 8) { setMsg({ ok: false, text: "비밀번호는 최소 8자 이상이어야 합니다." }); return; } if (pw !== pw2) { setMsg({ ok: false, text: "비밀번호가 일치하지 않습니다." }); return; } setBusy(true); setMsg(null); const r = await window.storeActions.resetPassword(tok, pw); setBusy(false); if (r && r.ok) { setMsg({ ok: true, text: "비밀번호가 변경됐어요. 잠시 후 자동 로그인됩니다." }); setTimeout(() => { location.hash = "#/upload"; }, 1200); } else { setMsg({ ok: false, text: (r && r.message) || "재설정 실패. 링크가 만료됐을 수 있습니다." }); } }; return (

새 비밀번호 설정

아래에 새 비밀번호를 입력해 주세요. (8자 이상)

setPw(e.target.value)} /> setPw2(e.target.value)} /> {msg && {msg.text}}
); } Object.assign(window, { LoginPage });