352 lines
20 KiB
TypeScript
352 lines
20 KiB
TypeScript
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';
|
||
|
||
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="overflow-y-auto whitespace-pre-wrap px-5 py-4 text-[13px] leading-6 text-[#4b5563]">{termModal.loading ? '불러오는 중...' : termModal.body}</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>;
|
||
}
|