// AppealModal — opened from the disabled-state Lock card. Read-only email // (from the OAuth callback redirect query) + reason textarea (maxLength 2000) // + Submit/Cancel buttons. POSTs to /auth/appeal. Three terminal states: // 200 → success confirmation, hides Submit // 400 invalid_reason → inline error // 429 rate_limited → "今天的申訴次數已達上限,請明天再試" const APPEAL_REASON_MAX = 2000; const AppealModal = ({ open, lang, email, onClose }) => { const t = lang === 'zh'; const [reason, setReason] = React.useState(''); const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(null); const [success, setSuccess] = React.useState(false); const [rateLimited, setRateLimited] = React.useState(false); React.useEffect(() => { if (!open) return; setReason(''); setError(null); setSuccess(false); setRateLimited(false); setSubmitting(false); }, [open]); if (!open) return null; const handleSubmit = async () => { if (submitting) return; const trimmed = reason.trim(); if (trimmed.length < 1) { setError(t ? '請填寫申訴內容' : 'Please enter your appeal reason'); return; } if (trimmed.length > APPEAL_REASON_MAX) { setError(t ? `內容不可超過 ${APPEAL_REASON_MAX} 字元` : `Reason must be ${APPEAL_REASON_MAX} chars or fewer`); return; } setSubmitting(true); setError(null); try { // Public unauthenticated endpoint — use raw fetch (apiFetch would inject // CSRF + cookies which we don't have because disabled users have no // session). const res = await fetch(`${API_BASE}/auth/appeal`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, reason: trimmed }), }); if (res.status === 200) { setSuccess(true); return; } const body = await res.json().catch(() => null); const code = body?.detail?.error_code || body?.error_code; if (res.status === 429 || code === 'rate_limited') { setRateLimited(true); return; } if (res.status === 400 || code === 'invalid_reason') { setError(t ? '申訴內容格式不正確(不可空白,最多 2000 字)' : 'Invalid reason (must be non-empty, ≤2000 chars)'); return; } setError(`HTTP ${res.status}`); } catch (err) { setError(err.message || String(err)); } finally { setSubmitting(false); } }; return (
{t ? '已收到你的申訴,管理員會在 1-2 個工作天內回覆' : 'Your appeal has been received. An admin will reply within 1–2 business days.'}