platform/lms-adm-fe release promotion snapshot

This commit is contained in:
2026-08-07 04:00:48 +00:00
parent 6b45e51105
commit 8e15589413
+204 -19
View File
@@ -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<HTMLFormElement>) => {
event.preventDefault();
@@ -76,6 +81,16 @@ export function LoginPage({ onLogin }: LoginPageProps) {
<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>
@@ -132,35 +147,205 @@ export function LoginPage({ onLogin }: LoginPageProps) {
</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 flex items-center gap-4">
<span className="h-px flex-1 bg-[#e3e7ef]" />
<span className="text-[12px] font-bold text-[#9aa2b1]">또는</span>
<span className="h-px flex-1 bg-[#e3e7ef]" />
</div>
<div className="my-7 h-px bg-[#e3e7ef]" />
<p className="text-center text-[12px] font-bold text-[#64748b]">
강사 계정이 없으신가요?{' '}
<button
className="group flex h-12 w-full items-center justify-center gap-3 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-extrabold text-[#263141] shadow-sm transition hover:border-[#b8c0d0] hover:bg-[#fafbff] focus:outline-none focus:ring-4 focus:ring-[#4f46e5]/15"
className="font-extrabold text-[#4f46e5] hover:text-[#4338ca] hover:underline"
type="button"
onClick={() => setError('Google 로그인은 공통 인증 서버에 엔드포인트가 준비된 뒤 연결됩니다.')}
onClick={() => {
setError('');
setSignupNotice('');
setShowSignup(true);
}}
>
<GoogleMark />
Google로 계속
<ArrowRight className="ml-auto text-[#98a1b2] transition group-hover:translate-x-0.5 group-hover:text-[#4f46e5]" size={16} aria-hidden="true" />
회원가입
</button>
<p className="mt-7 text-center text-[11px] font-bold leading-5 text-[#8b93a3]">접근 권한이 없는 계정은 관리자에게 문의하세요.</p>
</p>
<p className="mt-5 text-center text-[11px] font-bold leading-5 text-[#8b93a3]">접근 권한이 없는 계정은 관리자에게 문의하세요.</p>
</>}
</div>
</section>
</main>
);
}
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<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}/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 (
<span className="grid h-5 w-5 place-items-center rounded-full border border-[#dbe1eb] bg-[#f8fafc] text-[15px] font-black leading-none text-[#4285f4]" aria-hidden="true">
<span>G</span>
</span>
<>
<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>;
}