fix(auth): resolve post-login navigate race condition and add version display (#68) (#70)

Root cause: when auth.me() failed during initial navigation, the catch block
called navigate('/login') without clearing _pendingLoginRedirect. The outer
finally then fired a second concurrent navigate('/login'), which held
isNavigating=true while running. If the user submitted the login form (or
iCloud Keychain autofilled credentials) before the second navigation
completed, navigate('/', user) was silently blocked by the isNavigating guard —
login appeared to succeed but the app never advanced to the dashboard.

Fix: clear _pendingLoginRedirect in the catch block so the finally handler
does not spawn the duplicate navigation.

Also adds a GET /api/v1/version endpoint (no auth required) and shows the
version on the login page, so users can verify their PWA has received the
latest cached JS.

Resolves #68

Co-authored-by: Ulas Kalayci <ulas.kalayci@googlemail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-04-21 08:19:53 +02:00
committed by GitHub
parent c1bdd4361d
commit d1ec7367a0
22 changed files with 711 additions and 657 deletions
+8
View File
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.20.41] - 2026-04-21
### Fixed
- Race condition in `router.js`: when `auth.me()` failed during initial navigation, `_pendingLoginRedirect` was not cleared before calling `navigate('/login')` from the catch block, causing the `finally` handler to launch a second concurrent navigation. If the second navigation was still in progress when the user submitted the login form, `navigate('/', user)` was silently blocked — login appeared to succeed but the dashboard never loaded (most noticeable on iOS Safari PWA with iCloud Keychain autofill)
### Added
- Version number displayed on the login page (fetched from new `GET /api/v1/version` endpoint, no auth required), so users can verify which release their PWA is running
## [0.20.40] - 2026-04-21
### Changed
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "oikos",
"version": "0.20.40",
"version": "0.20.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oikos",
"version": "0.20.40",
"version": "0.20.41",
"license": "MIT",
"dependencies": {
"bcrypt": "^6.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "oikos",
"version": "0.20.40",
"version": "0.20.41",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js",
"type": "module",
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "تسجيل الدخول",
"loggingIn": "جارٍ تسجيل الدخول…",
"tooManyAttempts": "محاولات كثيرة جداً. يرجى الانتظار قليلاً.",
"invalidCredentials": "بيانات اعتماد غير صالحة."
"invalidCredentials": "بيانات اعتماد غير صالحة.",
"version": "v{{version}}"
},
"install": {
"title": "تثبيت Oikos",
@@ -604,4 +605,4 @@
"unitMonth": "شهر",
"unitMonths": "أشهر"
}
}
}
+3 -2
View File
@@ -618,7 +618,8 @@
"loginButton": "Anmelden",
"loggingIn": "Wird angemeldet …",
"tooManyAttempts": "Zu viele Versuche. Bitte warte kurz.",
"invalidCredentials": "Ungültige Anmeldedaten."
"invalidCredentials": "Ungültige Anmeldedaten.",
"version": "v{{version}}"
},
"install": {
"title": "Oikos installieren",
@@ -676,4 +677,4 @@
"pendingBadgeTitle": "{{count}} fällige Erinnerung",
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "Σύνδεση",
"loggingIn": "Σύνδεση…",
"tooManyAttempts": "Πολλές προσπάθειες. Παρακαλώ περιμένετε λίγο.",
"invalidCredentials": "Λανθασμένα στοιχεία σύνδεσης."
"invalidCredentials": "Λανθασμένα στοιχεία σύνδεσης.",
"version": "v{{version}}"
},
"install": {
"title": "Εγκατάσταση Oikos",
@@ -604,4 +605,4 @@
"unitMonth": "μήνα",
"unitMonths": "μήνες"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "Log in",
"loggingIn": "Logging in…",
"tooManyAttempts": "Too many attempts. Please wait a moment.",
"invalidCredentials": "Invalid credentials."
"invalidCredentials": "Invalid credentials.",
"version": "v{{version}}"
},
"install": {
"title": "Install Oikos",
@@ -625,4 +626,4 @@
"pendingBadgeTitle": "{{count}} reminder due",
"pendingBadgeTitlePlural": "{{count}} reminders due"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "Iniciar sesión",
"loggingIn": "Iniciando sesión…",
"tooManyAttempts": "Demasiados intentos. Por favor, espera un momento.",
"invalidCredentials": "Credenciales incorrectas."
"invalidCredentials": "Credenciales incorrectas.",
"version": "v{{version}}"
},
"install": {
"title": "Instalar Oikos",
@@ -604,4 +605,4 @@
"unitMonth": "mes",
"unitMonths": "meses"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "Se connecter",
"loggingIn": "Connexion…",
"tooManyAttempts": "Trop de tentatives. Veuillez patienter un moment.",
"invalidCredentials": "Identifiants invalides."
"invalidCredentials": "Identifiants invalides.",
"version": "v{{version}}"
},
"install": {
"title": "Installer Oikos",
@@ -604,4 +605,4 @@
"unitMonth": "mois",
"unitMonths": "mois"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "लॉग इन",
"loggingIn": "लॉग इन हो रहा है…",
"tooManyAttempts": "बहुत अधिक प्रयास। कृपया थोड़ा प्रतीक्षा करें।",
"invalidCredentials": "अमान्य क्रेडेंशियल।"
"invalidCredentials": "अमान्य क्रेडेंशियल।",
"version": "v{{version}}"
},
"install": {
"title": "Oikos इंस्टॉल करें",
@@ -604,4 +605,4 @@
"unitMonth": "माह",
"unitMonths": "माह"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "Accedi",
"loggingIn": "Accesso in corso…",
"tooManyAttempts": "Troppi tentativi. Attendi un momento.",
"invalidCredentials": "Credenziali non valide."
"invalidCredentials": "Credenziali non valide.",
"version": "v{{version}}"
},
"install": {
"title": "Installa Oikos",
@@ -604,4 +605,4 @@
"unitMonth": "mese",
"unitMonths": "mesi"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "ログイン",
"loggingIn": "ログイン中…",
"tooManyAttempts": "試行回数が多すぎます。しばらくお待ちください。",
"invalidCredentials": "ユーザー名またはパスワードが正しくありません。"
"invalidCredentials": "ユーザー名またはパスワードが正しくありません。",
"version": "v{{version}}"
},
"install": {
"title": "Oikos をインストール",
@@ -604,4 +605,4 @@
"unitMonth": "ヶ月",
"unitMonths": "ヶ月"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "Entrar",
"loggingIn": "Entrando…",
"tooManyAttempts": "Muitas tentativas. Por favor, aguarde.",
"invalidCredentials": "Credenciais inválidas."
"invalidCredentials": "Credenciais inválidas.",
"version": "v{{version}}"
},
"install": {
"title": "Instalar Oikos",
@@ -604,4 +605,4 @@
"unitMonth": "mês",
"unitMonths": "meses"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "Войти",
"loggingIn": "Вход…",
"tooManyAttempts": "Слишком много попыток. Подождите немного.",
"invalidCredentials": "Неверные данные для входа."
"invalidCredentials": "Неверные данные для входа.",
"version": "v{{version}}"
},
"install": {
"title": "Установить Oikos",
@@ -604,4 +605,4 @@
"unitMonth": "месяц",
"unitMonths": "месяцев"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "Logga in",
"loggingIn": "Loggar in...",
"tooManyAttempts": "För många försök. Vänta ett ögonblick.",
"invalidCredentials": "Ogiltiga användaruppgifter."
"invalidCredentials": "Ogiltiga användaruppgifter.",
"version": "v{{version}}"
},
"install": {
"title": "Installera Oikos",
@@ -604,4 +605,4 @@
"unitMonth": "månad",
"unitMonths": "månader"
}
}
}
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "Giriş yap",
"loggingIn": "Giriş yapılıyor…",
"tooManyAttempts": "Çok fazla deneme. Lütfen bir süre bekleyin.",
"invalidCredentials": "Geçersiz kimlik bilgileri."
"invalidCredentials": "Geçersiz kimlik bilgileri.",
"version": "v{{version}}"
},
"install": {
"title": "Oikos'u Yükle",
@@ -604,4 +605,4 @@
"unitMonth": "ay",
"unitMonths": "ay"
}
}
}
+627 -626
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -567,7 +567,8 @@
"loginButton": "登录",
"loggingIn": "登录中…",
"tooManyAttempts": "尝试次数过多,请稍后再试。",
"invalidCredentials": "用户名或密码错误。"
"invalidCredentials": "用户名或密码错误。",
"version": "v{{version}}"
},
"install": {
"title": "安装 Oikos",
@@ -604,4 +605,4 @@
"unitMonth": "个月",
"unitMonths": "个月"
}
}
}
+9
View File
@@ -7,6 +7,8 @@
import { auth } from '/api.js';
import { t } from '/i18n.js';
const VERSION_URL = '/api/v1/version';
/**
* Rendert die Login-Seite in den gegebenen Container.
* @param {HTMLElement} container
@@ -56,12 +58,19 @@ export async function render(container) {
</button>
</form>
</div>
<p class="login-version" id="login-version"></p>
</main>
`;
const form = container.querySelector('#login-form');
const errorEl = container.querySelector('#login-error');
const submitBtn = container.querySelector('#login-btn');
const versionEl = container.querySelector('#login-version');
fetch(VERSION_URL)
.then((r) => r.json())
.then((d) => { versionEl.textContent = t('login.version', { version: d.version }); })
.catch(() => {});
form.addEventListener('submit', async (e) => {
e.preventDefault();
+4
View File
@@ -171,6 +171,10 @@ async function navigate(path, userOrPushState = true, pushState = true) {
} catch {
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
isNavigating = false;
// _pendingLoginRedirect leeren: der catch ruft navigate('/login') direkt auf,
// der finally soll keinen zweiten Aufruf starten (würde isNavigating=true setzen,
// während die Login-Seite rendert, und so post-login navigate blockieren).
_pendingLoginRedirect = false;
navigate('/login');
return;
}
+8
View File
@@ -55,3 +55,11 @@
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.login-version {
margin-top: var(--space-4);
font-size: var(--text-xs);
color: var(--color-text-tertiary, var(--color-text-secondary));
text-align: center;
opacity: 0.6;
}
+10
View File
@@ -8,6 +8,7 @@ import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import path from 'path';
import { readFileSync } from 'node:fs';
import { createLogger } from './logger.js';
import * as db from './db.js';
import { router as authRouter, sessionMiddleware, requireAuth } from './auth.js';
@@ -32,6 +33,10 @@ const log = createLogger('Server');
const logSync = createLogger('Sync');
const logOikos = createLogger('Oikos');
const { version: APP_VERSION } = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf-8')
);
const app = express();
const PORT = process.env.PORT || 3000;
@@ -155,6 +160,11 @@ app.use('/api/', apiLimiter);
// --------------------------------------------------------
app.use('/api/v1/auth', authRouter);
// Versionsinformation - keine Authentifizierung erforderlich (Login-Seite benötigt diese)
app.get('/api/v1/version', (req, res) => {
res.json({ version: APP_VERSION });
});
// Alle weiteren API-Routen erfordern Authentifizierung + CSRF-Schutz
app.use('/api/v1', requireAuth);
app.use('/api/v1', csrfMiddleware);