/* 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 (
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); }}
/>
가입은 운영자 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 (
);
}
// ---- 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 (
);
}
Object.assign(window, { LoginPage });