Files
lms-adm-fe/src/pages/login/LoginPage.tsx
T

363 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, type FormEvent } from 'react';
import { CheckCircle2, Eye, EyeOff } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { BrandMark } from '../../components/BrandMark';
import { loginInstructor } from '../../lib/auth';
import type { AuthSession } from '../../types/auth';
type LoginPageProps = {
onLogin: (session: AuthSession) => void;
};
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_LAUNCHER_API_BASE_URL ?? 'https://portal-api.autonomystudio.net/api').replace(/\/+$/, '');
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function LoginPage({ onLogin }: LoginPageProps) {
const rememberedEmail = window.localStorage.getItem(REMEMBERED_EMAIL_STORAGE_KEY);
const [email, setEmail] = useState(rememberedEmail ?? DEFAULT_EMAIL);
const [password, setPassword] = useState(DEFAULT_PASSWORD);
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<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
setError('');
try {
const session = await loginInstructor(email, password);
if (rememberEmail) {
window.localStorage.setItem(REMEMBERED_EMAIL_STORAGE_KEY, email);
} else {
window.localStorage.removeItem(REMEMBERED_EMAIL_STORAGE_KEY);
}
onLogin(session);
} catch (loginError) {
setError(loginError instanceof Error ? loginError.message : '로그인에 실패했습니다.');
} finally {
setLoading(false);
}
};
return (
<main className="grid min-h-screen bg-white text-[#111827] lg:grid-cols-[minmax(420px,58%)_minmax(360px,42%)]">
<section className="relative flex min-h-[42vh] overflow-hidden bg-[linear-gradient(145deg,#3b82f6_0%,#7048e8_54%,#4f46e5_100%)] px-7 py-8 text-white sm:px-12 lg:min-h-screen lg:px-[60px]">
<div className="absolute inset-0 opacity-25">
<div className="absolute left-[14%] top-[18%] h-40 w-40 rounded-full bg-white blur-3xl" />
<div className="absolute bottom-[18%] right-[12%] h-56 w-56 rounded-full bg-[#93c5fd] blur-3xl" />
</div>
<div className="relative z-10 flex w-full max-w-[580px] flex-col">
<BrandMark />
<div className="my-auto py-12 lg:py-0">
<p className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.16em] text-white/70">Learning Management System</p>
<h1 className="max-w-[520px] text-[34px] font-black leading-[1.12] tracking-[0] sm:text-[44px]">
<span className="text-[#67e8f9]">자율주행</span> 교육 플랫폼
</h1>
<p className="mt-5 max-w-[410px] text-[14px] font-bold leading-6 text-white/82">
실제 하드웨어와 시뮬레이션을 활용한 차세대 학습 관리 시스템
</p>
<div className="mt-8 grid max-w-[410px] gap-2.5 rounded-xl bg-white/12 p-4 ring-1 ring-white/18 backdrop-blur">
{['실시간 모니터링 및 통합 관리', '하드웨어 플랫폼 상태 추적', 'AI 가이드 에스컬레이션'].map((item) => (
<div className="flex items-center gap-3 text-[12px] font-bold text-white/88" key={item}>
<CheckCircle2 size={16} aria-hidden="true" />
{item}
</div>
))}
</div>
</div>
<p className="text-[12px] font-bold text-white/55">© 2026. All rights reserved.</p>
</div>
</section>
<section className="flex min-h-[58vh] items-center justify-center px-7 py-10 sm:px-12 lg:min-h-screen">
<div className="w-full max-w-[420px]">
{showSignup ? (
<InstructorSignupForm
onCancel={() => setShowSignup(false)}
onComplete={(registeredEmail) => {
setEmail(registeredEmail);
setShowSignup(false);
setSignupNotice('회원가입이 완료되었습니다. 강사 계정으로 로그인할 수 있습니다.');
}}
/>
) : <>
<div className="mb-8">
<h2 className="text-[26px] font-black tracking-[0] text-[#4f46e5]">강사 로그인</h2>
<p className="mt-2.5 text-[13px] font-bold text-[#6b7280]">이메일과 비밀번호를 입력해주세요.</p>
</div>
<form className="grid gap-4" onSubmit={handleSubmit}>
<label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
이메일
<input
className="h-11 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12"
name="email"
placeholder="이메일을 입력하세요"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
비밀번호
<input
className="h-11 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12"
name="password"
placeholder="비밀번호를 입력하세요"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<div className="flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-[12px] font-bold text-[#64748b]">
<input
className="h-4 w-4 rounded border-[#d8dde8] accent-[#4f46e5]"
type="checkbox"
checked={rememberEmail}
onChange={(event) => setRememberEmail(event.target.checked)}
/>
아이디 기억
</label>
<button className="text-[12px] font-extrabold text-[#4f46e5]" type="button">
비밀번호를 잊으셨나요?
</button>
</div>
<button
className="h-12 rounded-lg bg-[#4f46e5] text-[14px] font-extrabold text-white shadow-[0_10px_24px_rgba(79,70,229,0.22)] transition hover:bg-[#4338ca] focus:outline-none focus:ring-4 focus:ring-[#4f46e5]/20 disabled:cursor-not-allowed disabled:opacity-70"
type="submit"
disabled={loading}
>
{loading ? '로그인 중...' : '로그인'}
</button>
{error ? <p className="text-center text-[12px] font-bold text-[#dc2626]">{error}</p> : null}
{signupNotice ? <p className="text-center text-[12px] font-bold text-[#047857]">{signupNotice}</p> : null}
</form>
<div className="my-7 h-px bg-[#e3e7ef]" />
<p className="text-center text-[12px] font-bold text-[#64748b]">
강사 계정이 없으신가요?{' '}
<button
className="font-extrabold text-[#4f46e5] hover:text-[#4338ca] hover:underline"
type="button"
onClick={() => {
setError('');
setSignupNotice('');
setShowSignup(true);
}}
>
회원가입
</button>
</p>
<p className="mt-5 text-center text-[11px] font-bold leading-5 text-[#8b93a3]">접근 권한이 없는 계정은 관리자에게 문의하세요.</p>
</>}
</div>
</section>
</main>
);
}
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<HTMLFormElement>) => {
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}/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 (
<>
<div className="mb-6">
<div className="flex items-center gap-3">
<button className="text-[12px] font-extrabold text-[#64748b] hover:text-[#4f46e5]" type="button" onClick={onCancel}>로그인</button>
<span className="h-3 w-px bg-[#d8dde8]" />
<h2 className="text-[26px] font-black tracking-[0] text-[#4f46e5]">강사 회원가입</h2>
</div>
<p className="mt-2.5 text-[13px] font-bold text-[#6b7280]">강사 가입 코드를 입력해주세요.</p>
</div>
<form className="grid gap-3" onSubmit={handleSubmit}>
<SignupInput label="이름" value={name} onChange={setName} placeholder="이름을 입력하세요" />
<div className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
이메일
<div className="flex gap-2">
<input className="h-11 min-w-0 flex-1 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12" type="email" value={email} onChange={(event) => handleEmailChange(event.target.value)} placeholder="이메일을 입력하세요" required />
<button className="shrink-0 rounded-lg border border-[#c7d2fe] px-3 text-[12px] font-extrabold text-[#4f46e5] hover:bg-[#eef2ff] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={() => void sendVerificationCode()} disabled={sendingCode || codeTimer > 0 || !normalizedEmail || Boolean(emailError)}>
{sendingCode ? '발송 중' : codeTimer > 0 ? `${codeTimer}초` : codeSent ? '재발송' : '인증 발송'}
</button>
</div>
{emailError ? <p className="text-[11px] text-[#dc2626]">{emailError}</p> : null}
</div>
<SignupInput label="인증코드" value={verificationCode} onChange={(value) => setVerificationCode(value.replace(/\D/g, '').slice(0, 6))} placeholder="이메일 인증코드 6자리를 입력하세요" inputMode="numeric" />
{codeSent ? <p className="-mt-1 text-[11px] font-bold text-[#047857]">인증코드가 이메일로 발송되었습니다.</p> : null}
<SignupInput label="강사 가입 코드" value={instructorInviteCode} onChange={(value) => setInstructorInviteCode(value.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 6))} placeholder="조직에서 받은 6자리 코드를 입력하세요" />
<PasswordInput label="비밀번호" value={password} onChange={setPassword} placeholder="8자 이상 입력하세요" visible={showPassword} onToggle={() => setShowPassword((value) => !value)} />
<PasswordInput label="비밀번호 확인" value={passwordConfirm} onChange={setPasswordConfirm} placeholder="비밀번호를 다시 입력하세요" visible={showPasswordConfirm} onToggle={() => setShowPasswordConfirm((value) => !value)} />
<div className="mt-1 grid gap-2 text-[12px] font-bold text-[#64748b]">
<label className="flex items-center gap-2"><input className="h-4 w-4 rounded border-[#d8dde8] accent-[#4f46e5]" type="checkbox" checked={agreeTerms} onChange={(event) => setAgreeTerms(event.target.checked)} /><button className="font-extrabold text-[#4f46e5] hover:underline" type="button" onClick={() => void openTermModal('terms')}>이용약관</button> 동의 (필수)</label>
<label className="flex items-center gap-2"><input className="h-4 w-4 rounded border-[#d8dde8] accent-[#4f46e5]" type="checkbox" checked={agreePrivacy} onChange={(event) => setAgreePrivacy(event.target.checked)} /><button className="font-extrabold text-[#4f46e5] hover:underline" type="button" onClick={() => void openTermModal('privacy')}>개인정보 처리방침</button> 동의 (필수)</label>
</div>
{error ? <p className="text-center text-[12px] font-bold text-[#dc2626]">{error}</p> : null}
<button className="mt-1 h-12 rounded-lg bg-[#4f46e5] text-[14px] font-extrabold text-white shadow-[0_10px_24px_rgba(79,70,229,0.22)] transition hover:bg-[#4338ca] disabled:cursor-not-allowed disabled:opacity-70" type="submit" disabled={submitting}>{submitting ? '가입 중...' : '회원가입'}</button>
</form>
{termModal ? <div className="fixed inset-0 z-50 flex items-center justify-center bg-[#111827]/45 px-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={termModal.title}>
<div className="flex max-h-[78vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border border-[#d8dde8] bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-[#e3e7ef] px-5 py-4"><h3 className="text-[15px] font-extrabold text-[#303747]">{termModal.title}</h3><button className="text-[20px] leading-none text-[#64748b] hover:text-[#111827]" type="button" onClick={() => setTermModal(null)} aria-label="닫기">×</button></div>
<div className="min-h-0 overflow-y-auto px-5 py-4 text-[13px] leading-6 text-[#4b5563]">
{termModal.loading ? '불러오는 중...' : (
<div className="break-words [overflow-wrap:anywhere] [&_h1]:mb-4 [&_h1]:text-xl [&_h1]:font-bold [&_h2]:mb-3 [&_h2]:mt-6 [&_h2]:text-lg [&_h2]:font-bold [&_h3]:mb-2 [&_h3]:mt-4 [&_h3]:text-base [&_h3]:font-bold [&_p]:my-3 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_blockquote]:border-l-4 [&_blockquote]:border-[#d8dde8] [&_blockquote]:pl-4 [&_hr]:my-5 [&_hr]:border-[#d8dde8] [&_a]:text-[#2563eb] [&_a]:underline [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-[#f1f5f9] [&_pre]:p-3 [&_th]:border [&_th]:border-[#d8dde8] [&_th]:bg-[#f1f5f9] [&_th]:px-3 [&_th]:py-2 [&_td]:border [&_td]:border-[#d8dde8] [&_td]:px-3 [&_td]:py-2">
<ReactMarkdown remarkPlugins={[remarkGfm]} skipHtml components={{
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
table: ({ node: _node, ...props }) => <div className="my-3 overflow-x-auto"><table {...props} className="w-full border-collapse text-left" /></div>,
}}>{termModal.body}</ReactMarkdown>
</div>
)}
</div>
</div>
</div> : null}
</>
);
}
type SignupInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
inputMode?: 'numeric';
};
function SignupInput({ label, value, onChange, placeholder, inputMode }: SignupInputProps) {
return <label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">{label}<input className="h-11 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12" value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} inputMode={inputMode} required /></label>;
}
type PasswordInputProps = Omit<SignupInputProps, 'inputMode'> & { visible: boolean; onToggle: () => void };
function PasswordInput({ label, value, onChange, placeholder, visible, onToggle }: PasswordInputProps) {
return <label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">{label}<span className="relative"><input className="h-11 w-full rounded-lg border border-[#d8dde8] bg-white px-4 pr-11 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12" type={visible ? 'text' : 'password'} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} required /><button className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8b93a3] hover:text-[#4f46e5]" type="button" onClick={onToggle} aria-label={visible ? '비밀번호 숨기기' : '비밀번호 보기'}>{visible ? <EyeOff size={17} /> : <Eye size={17} />}</button></span></label>;
}