Recover from expired sessions and malformed requests in the dashboard
Three failure paths that ended at an English HTTP status line or at no
recovery at all:
An empty or non-numeric withdrawal amount parsed to NaN, which JSON.stringify
sends as null, which pydantic rejects with a 422 — and FastAPI's validation
errors use a list of field objects rather than the {code, message} shape, so
apiErrorMessage fell through to res.statusText and the user read
"Unprocessable Content". The amount is now checked before the request, and the
list shape maps to a translated "invalid request" as a backstop for any other
field that fails validation.
A token the server no longer accepts left the dashboard looking logged in
while every poll failed, re-toasting "session expired" indefinitely. call()
now logs out on that specific code, dropping back to the login form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+21
-4
@@ -51,7 +51,13 @@ async function call(method, path, body) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(apiErrorMessage(data.detail) || res.statusText);
|
||||
if (!res.ok) {
|
||||
// A token the server no longer accepts can't be recovered from by retrying:
|
||||
// without this every poll keeps failing against a dashboard that still looks
|
||||
// logged in, toasting "session expired" forever. Drop back to the login form.
|
||||
if (res.status === 401 && data.detail?.code === 'session_expired' && token) logout();
|
||||
throw new Error(apiErrorMessage(data.detail) || res.statusText);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -62,6 +68,11 @@ async function call(method, path, body) {
|
||||
function apiErrorMessage(detail) {
|
||||
if (!detail) return null;
|
||||
if (typeof detail === 'string') return detail; // endpoints still returning a bare string
|
||||
// FastAPI's own request-validation failures (422) use a list of field errors
|
||||
// instead, in English and phrased for an API client ("Input should be a valid
|
||||
// integer"). Nothing here can act on which field it was, so say the one useful
|
||||
// thing — the request was malformed — in the user's language.
|
||||
if (Array.isArray(detail)) return t('error.invalid_request');
|
||||
return tOrNull('error.' + detail.code, errorParams(detail.params)) || detail.message || null;
|
||||
}
|
||||
|
||||
@@ -702,9 +713,15 @@ async function withdraw() {
|
||||
const btn = document.getElementById('withdraw-btn');
|
||||
const ext = document.getElementById('wd-address').value;
|
||||
const isFullAmount = document.getElementById('wd-full-amount').checked;
|
||||
const amtSats = isFullAmount
|
||||
? myBalanceSats
|
||||
: Math.round(parseFloat(document.getElementById('wd-amount').value) * SATS_PER_PLM);
|
||||
const amount = parseFloat(document.getElementById('wd-amount').value);
|
||||
// Caught here rather than left to the server: an empty or non-numeric field
|
||||
// parses to NaN, which JSON.stringify sends as null, which comes back as a
|
||||
// 422 whose only readable text is an English HTTP status line.
|
||||
if (!isFullAmount && !(amount > 0)) {
|
||||
toast(t('error.invalid_amount'), 'error');
|
||||
return;
|
||||
}
|
||||
const amtSats = isFullAmount ? myBalanceSats : Math.round(amount * SATS_PER_PLM);
|
||||
await withLoading(btn, t('loading.sending'), async () => {
|
||||
try {
|
||||
await call('POST', '/withdrawals', { external_address: ext, amount_sats: amtSats });
|
||||
|
||||
@@ -130,6 +130,8 @@ const TRANSLATIONS = {
|
||||
'error.invalid_credentials': 'Wrong username or password.',
|
||||
'error.derivation_index_conflict': 'Registration failed, please try again.',
|
||||
'error.session_expired': 'Session expired, please log in again.',
|
||||
'error.invalid_request': 'Invalid request, please check the entered data.',
|
||||
'error.invalid_amount': 'Enter an amount greater than zero.',
|
||||
|
||||
'loading.creating': 'Creating…',
|
||||
'loading.loggingIn': 'Logging in…',
|
||||
@@ -260,6 +262,8 @@ const TRANSLATIONS = {
|
||||
'error.invalid_credentials': 'Username o password errati.',
|
||||
'error.derivation_index_conflict': 'Registrazione non riuscita, riprova.',
|
||||
'error.session_expired': 'Sessione scaduta, accedi di nuovo.',
|
||||
'error.invalid_request': 'Richiesta non valida, controlla i dati inseriti.',
|
||||
'error.invalid_amount': 'Inserisci un importo maggiore di zero.',
|
||||
|
||||
'loading.creating': 'Creazione…',
|
||||
'loading.loggingIn': 'Accesso…',
|
||||
@@ -390,6 +394,8 @@ const TRANSLATIONS = {
|
||||
'error.invalid_credentials': 'Usuario o contraseña incorrectos.',
|
||||
'error.derivation_index_conflict': 'No se pudo completar el registro, inténtalo de nuevo.',
|
||||
'error.session_expired': 'Sesión caducada, vuelve a iniciar sesión.',
|
||||
'error.invalid_request': 'Solicitud no válida, revisa los datos introducidos.',
|
||||
'error.invalid_amount': 'Introduce un importe mayor que cero.',
|
||||
|
||||
'loading.creating': 'Creando…',
|
||||
'loading.loggingIn': 'Entrando…',
|
||||
@@ -520,6 +526,8 @@ const TRANSLATIONS = {
|
||||
'error.invalid_credentials': "Nom d'utilisateur ou mot de passe incorrect.",
|
||||
'error.derivation_index_conflict': "L'inscription a échoué, veuillez réessayer.",
|
||||
'error.session_expired': 'Session expirée, veuillez vous reconnecter.',
|
||||
'error.invalid_request': 'Requête invalide, vérifiez les données saisies.',
|
||||
'error.invalid_amount': 'Saisissez un montant supérieur à zéro.',
|
||||
|
||||
'loading.creating': 'Création…',
|
||||
'loading.loggingIn': 'Connexion…',
|
||||
@@ -650,6 +658,8 @@ const TRANSLATIONS = {
|
||||
'error.invalid_credentials': 'Benutzername oder Passwort falsch.',
|
||||
'error.derivation_index_conflict': 'Registrierung fehlgeschlagen, bitte erneut versuchen.',
|
||||
'error.session_expired': 'Sitzung abgelaufen, bitte melde dich erneut an.',
|
||||
'error.invalid_request': 'Ungültige Anfrage, bitte überprüfe die eingegebenen Daten.',
|
||||
'error.invalid_amount': 'Gib einen Betrag größer als null ein.',
|
||||
|
||||
'loading.creating': 'Wird erstellt…',
|
||||
'loading.loggingIn': 'Anmeldung…',
|
||||
@@ -780,6 +790,8 @@ const TRANSLATIONS = {
|
||||
'error.invalid_credentials': 'Неверное имя пользователя или пароль.',
|
||||
'error.derivation_index_conflict': 'Не удалось завершить регистрацию, попробуйте ещё раз.',
|
||||
'error.session_expired': 'Сессия истекла, войдите снова.',
|
||||
'error.invalid_request': 'Некорректный запрос, проверьте введённые данные.',
|
||||
'error.invalid_amount': 'Введите сумму больше нуля.',
|
||||
|
||||
'loading.creating': 'Создание…',
|
||||
'loading.loggingIn': 'Вход…',
|
||||
@@ -910,6 +922,8 @@ const TRANSLATIONS = {
|
||||
'error.invalid_credentials': '用户名或密码错误。',
|
||||
'error.derivation_index_conflict': '注册失败,请重试。',
|
||||
'error.session_expired': '会话已过期,请重新登录。',
|
||||
'error.invalid_request': '请求无效,请检查填写的内容。',
|
||||
'error.invalid_amount': '请输入大于零的金额。',
|
||||
|
||||
'loading.creating': '正在创建…',
|
||||
'loading.loggingIn': '正在登录…',
|
||||
|
||||
Reference in New Issue
Block a user