// Login.jsx — orb + reveal panel. Wired to POST /auth/login (sign-in mode) // and POST /auth/register (sign-up mode, single-toggle UI). function Login({ onLogin }) { const [open, setOpen] = React.useState(false); const [mode, setMode] = React.useState('signin'); // 'signin' | 'signup' const [email, setEmail] = React.useState(''); const [password, setPassword] = React.useState(''); const [password2, setPassword2] = React.useState(''); const [displayName, setDisplayName] = React.useState(''); const [invite, setInvite] = React.useState(''); const [tos, setTos] = React.useState(false); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const panelRef = React.useRef(null); // On mobile, the panel sits below the orb in document flow. After the user // taps the orb, smoothly scroll the panel to the viewport center so the // form is immediately visible without needing a manual swipe. Desktop is // unaffected (panel is centered absolutely there). React.useEffect(() => { if (!open) return; const t = setTimeout(() => { if (panelRef.current && window.matchMedia('(max-width: 820px)').matches) { panelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 60); return () => clearTimeout(t); }, [open]); const switchTo = (next) => { setMode(next); setErr(null); setPassword(''); setPassword2(''); }; // Lightweight strength estimator. Counts character classes + length tier. // Not a security boundary — backend enforces a min length of 8 regardless. const strength = React.useMemo(() => { if (!password) return { label: '', pct: 0, tone: 'fg-5' }; const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^a-zA-Z0-9]/].filter(r => r.test(password)).length; const len = password.length; let score = 0; if (len >= 8) score++; if (len >= 12) score++; if (classes >= 2) score++; if (classes >= 3) score++; const tiers = [ { label: '매우 약함', pct: 10, tone: 'zg-danger' }, { label: '약함', pct: 30, tone: 'zg-danger' }, { label: '보통', pct: 55, tone: 'fg-4' }, { label: '강함', pct: 80, tone: 'fg-2' }, { label: '매우 강함', pct: 100, tone: 'fg-2' }, ]; return tiers[Math.min(score, 4)]; }, [password]); const canSubmit = mode === 'signin' ? !!(email && password) : !!(email && password && password === password2 && password.length >= 8 && tos); const submit = async (e) => { e.preventDefault(); if (busy || !canSubmit) return; setBusy(true); setErr(null); try { let data; if (mode === 'signin') { data = await window.API.login(email.trim(), password); } else { data = await window.API.register({ email: email.trim(), password, display_name: displayName.trim() || null, invite_code: invite.trim() || null, accepted_tos: tos, }); } if (onLogin) onLogin(data.access_token, data.user); } catch (ex) { setErr(ex.message || (mode === 'signin' ? 'Sign-in failed' : 'Sign-up failed')); } finally { setBusy(false); } }; const fillGuest = () => { setEmail('guest'); setPassword('guest'); setErr(null); setMode('signin'); }; return (
setOpen(true)}>
ZIGU
로고를 눌러 시작
GBR · 개발자

{mode === 'signin' ? '다시 오신 걸 환영합니다.' : '계정을 만들어 시작해요.'}

{mode === 'signup' && ( )} {mode === 'signup' && ( <>
{strength.label}
)} {mode === 'signin' && (
alert('비밀번호 분실 시 관리자에게 초기화를 요청하세요.')} style={{cursor:'pointer'}}>비밀번호 찾기?
)} {err && (
{err}
)}
또는
{mode === 'signin' ? ( <> ) : ( )}
서버 · {(window.API && window.API.serverUrl) || '/api'}
); } window.Login = Login;