// Auth layer — login, forgot, reset, invite panels + AuthGate wrapper const { useState, useEffect, createContext, useContext } = React; const _AuthCtx = createContext(null); // ── Styles ──────────────────────────────────────────────────────────────────── const S = { screen: { position: 'fixed', inset: 0, background: 'var(--canvas)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, }, card: { width: '100%', maxWidth: '400px', padding: '40px 36px', background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: '16px', display: 'flex', flexDirection: 'column', gap: '8px', }, wordmark: { fontFamily: 'var(--f-mono)', fontSize: '22px', fontWeight: 700, color: 'var(--mint)', letterSpacing: '-0.5px', marginBottom: '8px', textAlign: 'center', }, title: { fontFamily: 'var(--f-sans)', fontSize: '14px', fontWeight: 500, color: 'var(--text-meta)', textAlign: 'center', marginBottom: '16px', }, input: { width: '100%', padding: '10px 14px', background: '#111', border: '1px solid #333', borderRadius: '8px', color: 'var(--text)', fontFamily: 'var(--f-sans)', fontSize: '14px', outline: 'none', boxSizing: 'border-box', transition: 'border-color .15s', }, btn: { width: '100%', padding: '11px', background: 'var(--mint)', border: 'none', borderRadius: '8px', color: '#131313', fontFamily: 'var(--f-sans)', fontSize: '14px', fontWeight: 700, cursor: 'pointer', transition: 'opacity .15s', }, ghost: { width: '100%', padding: '10px', background: 'transparent', border: '1px solid #333', borderRadius: '8px', color: 'var(--text-muted)', fontFamily: 'var(--f-sans)', fontSize: '14px', cursor: 'pointer', }, link: { background: 'none', border: 'none', color: 'var(--text-meta)', fontSize: '13px', cursor: 'pointer', textDecoration: 'underline', padding: '4px 0', fontFamily: 'var(--f-sans)', }, msg: (ok) => ({ padding: '10px 14px', borderRadius: '8px', fontSize: '13px', background: ok ? 'rgba(60,255,208,.12)' : 'rgba(255,76,76,.12)', color: ok ? 'var(--mint)' : '#ff4c4c', }), privacyRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', fontSize: '13px', color: 'var(--text-meta)', }, gap16: { display: 'flex', flexDirection: 'column', gap: '12px' }, }; function Msg({ text, ok }) { if (!text) return null; return
{text}
; } // ── Login panel ─────────────────────────────────────────────────────────────── function LoginPanel({ onAuth, switchTo, appendMsg }) { const [email, setEmail] = useState(''); const [pw, setPw] = useState(''); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); const [needsVerify, setNeedsVerify] = useState(false); async function submit() { if (!email || !pw) { setMsg({ text: 'Rellena email y contraseña', ok: false }); return; } setBusy(true); try { const r = await fetch('/api/auth/login', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.trim().toLowerCase(), password: pw }), }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || 'Error al iniciar sesión'); } const me = await fetch('/api/auth/me', { cache: 'no-store', credentials: 'include' }); const user = await me.json(); onAuth(user); } catch (e) { setMsg({ text: e.message, ok: false }); setNeedsVerify(/verific/i.test(e.message || '')); } finally { setBusy(false); } } async function resendVerification() { if (!email) { setMsg({ text: 'Introduce tu email primero', ok: false }); return; } setBusy(true); try { await fetch('/api/auth/resend-verification', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.trim().toLowerCase() }), }); setMsg({ text: 'Si tu cuenta existe y no está verificada, te hemos reenviado el email.', ok: true }); setNeedsVerify(false); } catch { setMsg({ text: 'Error de red. Inténtalo de nuevo.', ok: false }); } finally { setBusy(false); } } return (
{appendMsg &&
{appendMsg.text}
} setEmail(e.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} /> setPw(e.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} /> {needsVerify && ( )}
); } // ── Forgot password panel ───────────────────────────────────────────────────── function ForgotPanel({ switchTo }) { const [email, setEmail] = useState(''); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); async function submit() { if (!email) { setMsg({ text: 'Introduce tu email', ok: false }); return; } setBusy(true); try { const r = await fetch('/api/auth/request-reset', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.trim().toLowerCase() }), }); if (r.ok) setMsg({ text: 'Si el email existe, recibirás un enlace en unos minutos.', ok: true }); else { const e = await r.json().catch(() => ({})); setMsg({ text: e.detail || 'Error al enviar.', ok: false }); } } catch { setMsg({ text: 'Error de red. Inténtalo de nuevo.', ok: false }); } finally { setBusy(false); } } return (
setEmail(e.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} />
); } // ── Reset password panel ────────────────────────────────────────────────────── function ResetPanel({ resetToken, switchTo }) { const [pw, setPw] = useState(''); const [pw2, setPw2] = useState(''); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); async function submit() { if (pw.length < 8) { setMsg({ text: 'La contraseña debe tener al menos 8 caracteres.', ok: false }); return; } if (pw !== pw2) { setMsg({ text: 'Las contraseñas no coinciden.', ok: false }); return; } setBusy(true); try { const r = await fetch('/api/auth/reset', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: resetToken, password: pw }), }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || 'Token inválido o caducado'); } setMsg({ text: 'Contraseña actualizada. Ya puedes iniciar sesión.', ok: true }); history.replaceState({}, '', '/'); setTimeout(() => switchTo('login'), 1800); } catch (e) { setMsg({ text: e.message, ok: false }); } finally { setBusy(false); } } return (
setPw(e.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} /> setPw2(e.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} />
); } // ── Invite redemption panel ─────────────────────────────────────────────────── function InvitePanel({ inviteCode, switchTo }) { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [pw, setPw] = useState(''); const [privacy, setPrivacy] = useState(false); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); async function submit() { if (!email || !pw) { setMsg({ text: 'Rellena email y contraseña', ok: false }); return; } if (!privacy) { setMsg({ text: 'Debes aceptar la política de privacidad', ok: false }); return; } setBusy(true); try { const r = await fetch('/api/auth/redeem-invite', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: inviteCode, email: email.trim().toLowerCase(), password: pw, name, accepted_privacy: true }), }); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.detail || 'Error al crear la cuenta'); setMsg({ text: 'Cuenta creada. Revisa tu email para verificarla antes de iniciar sesión.', ok: true }); history.replaceState({}, '', '/'); setTimeout(() => switchTo('login'), 2200); } catch (e) { setMsg({ text: e.message, ok: false }); } finally { setBusy(false); } } return (
setName(e.target.value)} /> setEmail(e.target.value)} /> setPw(e.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} />
); } // ── Auth screen ─────────────────────────────────────────────────────────────── const TITLES = { login: 'Iniciar sesión', forgot: 'Recuperar contraseña', reset: 'Nueva contraseña', invite: 'Crear cuenta', }; function AuthScreen({ onAuth, initialPanel, resetToken, inviteCode }) { const [panel, setPanel] = useState(initialPanel || 'login'); const [verifyMsg, setVerifyMsg] = useState(null); useEffect(() => { const params = new URLSearchParams(window.location.search); const ver = params.get('verify_token'); if (ver) { setVerifyMsg({ text: 'Verificando email…', ok: true }); fetch(`/api/auth/verify-email?token=${encodeURIComponent(ver)}`, { credentials: 'include' }) .then(r => r.json()) .then(d => { setVerifyMsg({ text: d.message || 'Email verificado. Ya puedes iniciar sesión.', ok: true }); history.replaceState({}, '', '/'); setTimeout(() => setVerifyMsg(null), 3000); }) .catch(() => setVerifyMsg({ text: 'Error al verificar. El enlace puede haber caducado.', ok: false })); } }, []); return (
Ruticomidas
{TITLES[panel] || ''}
{panel === 'login' && } {panel === 'forgot' && } {panel === 'reset' && } {panel === 'invite' && }
); } // ── Loading screen ───────────────────────────────────────────────────────────── function LoadingScreen() { return (
Ruticomidas
Cargando…
); } // ── AuthGate ────────────────────────────────────────────────────────────────── function AuthGate({ children }) { const [user, setUser] = useState(undefined); // undefined=loading, null=unauthed, obj=authed const [initialPanel, setInitialPanel] = useState('login'); const [resetToken, setResetToken] = useState(null); const [inviteCode, setInviteCode] = useState(null); useEffect(() => { const params = new URLSearchParams(window.location.search); const inv = params.get('invite'); const rst = params.get('reset_token'); const ver = params.get('verify_token'); if (inv) { setInviteCode(inv); setInitialPanel('invite'); setUser(null); return; } if (rst) { setResetToken(rst); setInitialPanel('reset'); setUser(null); return; } if (ver) { setInitialPanel('login'); } fetch('/api/auth/me', { cache: 'no-store', credentials: 'include' }) .then(r => r.ok ? r.json() : null) .then(u => { if (u) { window.RC_Auth.currentUser = u; setUser(u); } else setUser(null); }) .catch(() => setUser(null)); }, []); function handleAuth(u) { window.RC_Auth.currentUser = u; setUser(u); } function logout() { fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}); window.RC_Auth.currentUser = null; setUser(null); } if (user === undefined) return ; if (user === null) return ( ); return ( <_AuthCtx.Provider value={{ user, logout }}> {children} ); } function useAuth() { return useContext(_AuthCtx); } window.RC_Auth = { currentUser: null, AuthGate, useAuth }; // Reload on 401 from api.jsx wrapper window.RC_API.setOn401(() => { window.RC_Auth.currentUser = null; window.location.reload(); });