diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx index 0658f0e..b8929e1 100644 --- a/src/pages/login/LoginPage.tsx +++ b/src/pages/login/LoginPage.tsx @@ -1,5 +1,5 @@ -import { useState, type FormEvent } from 'react'; -import { ArrowRight, CheckCircle2 } from 'lucide-react'; +import { useEffect, useState, type FormEvent } from 'react'; +import { CheckCircle2, Eye, EyeOff } from 'lucide-react'; import { BrandMark } from '../../components/BrandMark'; import { loginInstructor } from '../../lib/auth'; import type { AuthSession } from '../../types/auth'; @@ -11,6 +11,9 @@ type LoginPageProps = { const REMEMBERED_EMAIL_STORAGE_KEY = 'atnmy.lms.admin.login.rememberedEmail'; const DEFAULT_EMAIL = ''; const DEFAULT_PASSWORD = ''; +const AUTH_BASE_URL = (import.meta.env.VITE_ATNMY_AUTH_URL ?? import.meta.env.VITE_ADS_IDE_AUTH_URL ?? 'https://auth.autonomystudio.net').replace(/\/+$/, ''); +const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL ?? 'http://localhost:4200').replace(/\/+$/, ''); +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function LoginPage({ onLogin }: LoginPageProps) { const rememberedEmail = window.localStorage.getItem(REMEMBERED_EMAIL_STORAGE_KEY); @@ -19,6 +22,8 @@ export function LoginPage({ onLogin }: LoginPageProps) { const [rememberEmail, setRememberEmail] = useState(Boolean(rememberedEmail)); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [showSignup, setShowSignup] = useState(false); + const [signupNotice, setSignupNotice] = useState(''); const handleSubmit = async (event: FormEvent) => { event.preventDefault(); @@ -76,12 +81,22 @@ export function LoginPage({ onLogin }: LoginPageProps) {
-
-

강사 로그인

-

이메일과 비밀번호를 입력해주세요.

-
+ {showSignup ? ( + setShowSignup(false)} + onComplete={(registeredEmail) => { + setEmail(registeredEmail); + setShowSignup(false); + setSignupNotice('회원가입이 완료되었습니다. 강사 계정으로 로그인할 수 있습니다.'); + }} + /> + ) : <> +
+

강사 로그인

+

이메일과 비밀번호를 입력해주세요.

+
-
+
); } -function GoogleMark() { +type InstructorSignupFormProps = { + onCancel: () => void; + onComplete: (email: string) => void; +}; + +function InstructorSignupForm({ onCancel, onComplete }: InstructorSignupFormProps) { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [instructorInviteCode, setInstructorInviteCode] = useState(''); + const [verificationCode, setVerificationCode] = useState(''); + const [verificationEmail, setVerificationEmail] = useState(''); + const [codeSent, setCodeSent] = useState(false); + const [codeTimer, setCodeTimer] = useState(0); + const [sendingCode, setSendingCode] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); + const [agreeTerms, setAgreeTerms] = useState(false); + const [agreePrivacy, setAgreePrivacy] = useState(false); + const [error, setError] = useState(''); + const [termModal, setTermModal] = useState<{ title: string; body: string; loading: boolean } | null>(null); + const normalizedEmail = email.trim().toLowerCase(); + const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''; + + useEffect(() => { + if (codeTimer <= 0) return; + const timer = window.setTimeout(() => setCodeTimer((value) => value - 1), 1000); + return () => window.clearTimeout(timer); + }, [codeTimer]); + + const handleEmailChange = (value: string) => { + setEmail(value); + if (verificationEmail && value.trim().toLowerCase() !== verificationEmail) { + setCodeSent(false); + setVerificationCode(''); + setCodeTimer(0); + } + }; + + const sendVerificationCode = async () => { + if (!normalizedEmail || emailError) return; + setSendingCode(true); + setError(''); + try { + const response = await fetch(`${AUTH_BASE_URL}/auth/email-verification/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ email: normalizedEmail }), + }); + if (response.status === 409) throw new Error('이미 가입된 이메일입니다.'); + if (!response.ok) throw new Error('인증코드 발송에 실패했습니다.'); + + setCodeSent(true); + setVerificationEmail(normalizedEmail); + setVerificationCode(''); + setCodeTimer(60); + } catch (sendError) { + setCodeSent(false); + setError(sendError instanceof Error ? sendError.message : '인증코드 발송에 실패했습니다.'); + } finally { + setSendingCode(false); + } + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(''); + if (!name.trim()) return setError('이름을 입력해주세요.'); + if (emailError) return setError(emailError); + if (password.length < 8) return setError('비밀번호는 8자 이상이어야 합니다.'); + if (password !== passwordConfirm) return setError('비밀번호가 일치하지 않습니다.'); + if (!agreeTerms || !agreePrivacy) return setError('필수 약관에 동의해주세요.'); + if (!/^[A-Za-z0-9]{6}$/.test(instructorInviteCode.trim())) return setError('강사 가입 코드 6자리를 입력해주세요.'); + if (!/^\d{6}$/.test(verificationCode)) return setError('이메일 인증코드 6자리를 입력해주세요.'); + if (!codeSent || verificationEmail !== normalizedEmail) return setError('현재 이메일로 인증코드를 다시 발송해주세요.'); + + setSubmitting(true); + try { + const response = await fetch(`${AUTH_BASE_URL}/auth/instructor-signup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name: name.trim(), email: normalizedEmail, password, verificationCode, instructorInviteCode: instructorInviteCode.trim().toUpperCase() }), + }); + if (response.status === 409) throw new Error('이미 가입된 이메일입니다.'); + if (response.status === 400) throw new Error('인증코드 또는 강사 가입 코드가 올바르지 않습니다.'); + if (!response.ok) throw new Error('회원가입에 실패했습니다.'); + onComplete(normalizedEmail); + } catch (signupError) { + setError(signupError instanceof Error ? signupError.message : '회원가입에 실패했습니다.'); + } finally { + setSubmitting(false); + } + }; + + const openTermModal = async (key: 'terms' | 'privacy') => { + const title = key === 'terms' ? '이용약관' : '개인정보 처리방침'; + setTermModal({ title, body: '', loading: true }); + try { + const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/terms/${key}`); + if (!response.ok) throw new Error('약관을 불러오지 못했습니다.'); + const payload = await response.json() as { term?: { body?: string } }; + setTermModal({ title, body: payload.term?.body || '등록된 약관 내용이 없습니다.', loading: false }); + } catch (termError) { + setTermModal({ + title, + body: termError instanceof Error ? termError.message : '약관을 불러오지 못했습니다.', + loading: false, + }); + } + }; + return ( - + <> +
+
+ + +

강사 회원가입

+
+

조직에서 받은 강사 가입 코드를 입력해주세요.

+
+
+ +
+ 이메일 +
+ handleEmailChange(event.target.value)} placeholder="이메일을 입력하세요" required /> + +
+ {emailError ?

{emailError}

: null} +
+ setVerificationCode(value.replace(/\D/g, '').slice(0, 6))} placeholder="이메일 인증코드 6자리를 입력하세요" inputMode="numeric" /> + {codeSent ?

인증코드가 이메일로 발송되었습니다.

: null} + setInstructorInviteCode(value.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 6))} placeholder="조직에서 받은 6자리 코드를 입력하세요" /> + setShowPassword((value) => !value)} /> + setShowPasswordConfirm((value) => !value)} /> +
+ + +
+ {error ?

{error}

: null} + + + {termModal ?
+
+

{termModal.title}

+
{termModal.loading ? '불러오는 중...' : termModal.body}
+
+
: null} + ); } + +type SignupInputProps = { + label: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + inputMode?: 'numeric'; +}; + +function SignupInput({ label, value, onChange, placeholder, inputMode }: SignupInputProps) { + return ; +} + +type PasswordInputProps = Omit & { visible: boolean; onToggle: () => void }; + +function PasswordInput({ label, value, onChange, placeholder, visible, onToggle }: PasswordInputProps) { + return ; +}