platform/lms-adm-fe release promotion snapshot
This commit is contained in:
+215
-30
@@ -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,12 +81,22 @@ 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]">
|
||||
<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>
|
||||
{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}>
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
|
||||
이메일
|
||||
<input
|
||||
@@ -131,36 +146,206 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="text-center text-[12px] font-bold text-[#dc2626]">{error}</p> : null}
|
||||
</form>
|
||||
{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>
|
||||
|
||||
<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"
|
||||
type="button"
|
||||
onClick={() => setError('Google 로그인은 공통 인증 서버에 엔드포인트가 준비된 뒤 연결됩니다.')}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user