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:
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.20.40] - 2026-04-21
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.20.40",
|
"version": "0.20.41",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.20.40",
|
"version": "0.20.41",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"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.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "تسجيل الدخول",
|
"loginButton": "تسجيل الدخول",
|
||||||
"loggingIn": "جارٍ تسجيل الدخول…",
|
"loggingIn": "جارٍ تسجيل الدخول…",
|
||||||
"tooManyAttempts": "محاولات كثيرة جداً. يرجى الانتظار قليلاً.",
|
"tooManyAttempts": "محاولات كثيرة جداً. يرجى الانتظار قليلاً.",
|
||||||
"invalidCredentials": "بيانات اعتماد غير صالحة."
|
"invalidCredentials": "بيانات اعتماد غير صالحة.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "تثبيت Oikos",
|
"title": "تثبيت Oikos",
|
||||||
|
|||||||
@@ -618,7 +618,8 @@
|
|||||||
"loginButton": "Anmelden",
|
"loginButton": "Anmelden",
|
||||||
"loggingIn": "Wird angemeldet …",
|
"loggingIn": "Wird angemeldet …",
|
||||||
"tooManyAttempts": "Zu viele Versuche. Bitte warte kurz.",
|
"tooManyAttempts": "Zu viele Versuche. Bitte warte kurz.",
|
||||||
"invalidCredentials": "Ungültige Anmeldedaten."
|
"invalidCredentials": "Ungültige Anmeldedaten.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Oikos installieren",
|
"title": "Oikos installieren",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "Σύνδεση",
|
"loginButton": "Σύνδεση",
|
||||||
"loggingIn": "Σύνδεση…",
|
"loggingIn": "Σύνδεση…",
|
||||||
"tooManyAttempts": "Πολλές προσπάθειες. Παρακαλώ περιμένετε λίγο.",
|
"tooManyAttempts": "Πολλές προσπάθειες. Παρακαλώ περιμένετε λίγο.",
|
||||||
"invalidCredentials": "Λανθασμένα στοιχεία σύνδεσης."
|
"invalidCredentials": "Λανθασμένα στοιχεία σύνδεσης.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Εγκατάσταση Oikos",
|
"title": "Εγκατάσταση Oikos",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "Log in",
|
"loginButton": "Log in",
|
||||||
"loggingIn": "Logging in…",
|
"loggingIn": "Logging in…",
|
||||||
"tooManyAttempts": "Too many attempts. Please wait a moment.",
|
"tooManyAttempts": "Too many attempts. Please wait a moment.",
|
||||||
"invalidCredentials": "Invalid credentials."
|
"invalidCredentials": "Invalid credentials.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Install Oikos",
|
"title": "Install Oikos",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "Iniciar sesión",
|
"loginButton": "Iniciar sesión",
|
||||||
"loggingIn": "Iniciando sesión…",
|
"loggingIn": "Iniciando sesión…",
|
||||||
"tooManyAttempts": "Demasiados intentos. Por favor, espera un momento.",
|
"tooManyAttempts": "Demasiados intentos. Por favor, espera un momento.",
|
||||||
"invalidCredentials": "Credenciales incorrectas."
|
"invalidCredentials": "Credenciales incorrectas.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Instalar Oikos",
|
"title": "Instalar Oikos",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "Se connecter",
|
"loginButton": "Se connecter",
|
||||||
"loggingIn": "Connexion…",
|
"loggingIn": "Connexion…",
|
||||||
"tooManyAttempts": "Trop de tentatives. Veuillez patienter un moment.",
|
"tooManyAttempts": "Trop de tentatives. Veuillez patienter un moment.",
|
||||||
"invalidCredentials": "Identifiants invalides."
|
"invalidCredentials": "Identifiants invalides.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Installer Oikos",
|
"title": "Installer Oikos",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "लॉग इन",
|
"loginButton": "लॉग इन",
|
||||||
"loggingIn": "लॉग इन हो रहा है…",
|
"loggingIn": "लॉग इन हो रहा है…",
|
||||||
"tooManyAttempts": "बहुत अधिक प्रयास। कृपया थोड़ा प्रतीक्षा करें।",
|
"tooManyAttempts": "बहुत अधिक प्रयास। कृपया थोड़ा प्रतीक्षा करें।",
|
||||||
"invalidCredentials": "अमान्य क्रेडेंशियल।"
|
"invalidCredentials": "अमान्य क्रेडेंशियल।",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Oikos इंस्टॉल करें",
|
"title": "Oikos इंस्टॉल करें",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "Accedi",
|
"loginButton": "Accedi",
|
||||||
"loggingIn": "Accesso in corso…",
|
"loggingIn": "Accesso in corso…",
|
||||||
"tooManyAttempts": "Troppi tentativi. Attendi un momento.",
|
"tooManyAttempts": "Troppi tentativi. Attendi un momento.",
|
||||||
"invalidCredentials": "Credenziali non valide."
|
"invalidCredentials": "Credenziali non valide.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Installa Oikos",
|
"title": "Installa Oikos",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "ログイン",
|
"loginButton": "ログイン",
|
||||||
"loggingIn": "ログイン中…",
|
"loggingIn": "ログイン中…",
|
||||||
"tooManyAttempts": "試行回数が多すぎます。しばらくお待ちください。",
|
"tooManyAttempts": "試行回数が多すぎます。しばらくお待ちください。",
|
||||||
"invalidCredentials": "ユーザー名またはパスワードが正しくありません。"
|
"invalidCredentials": "ユーザー名またはパスワードが正しくありません。",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Oikos をインストール",
|
"title": "Oikos をインストール",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "Entrar",
|
"loginButton": "Entrar",
|
||||||
"loggingIn": "Entrando…",
|
"loggingIn": "Entrando…",
|
||||||
"tooManyAttempts": "Muitas tentativas. Por favor, aguarde.",
|
"tooManyAttempts": "Muitas tentativas. Por favor, aguarde.",
|
||||||
"invalidCredentials": "Credenciais inválidas."
|
"invalidCredentials": "Credenciais inválidas.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Instalar Oikos",
|
"title": "Instalar Oikos",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "Войти",
|
"loginButton": "Войти",
|
||||||
"loggingIn": "Вход…",
|
"loggingIn": "Вход…",
|
||||||
"tooManyAttempts": "Слишком много попыток. Подождите немного.",
|
"tooManyAttempts": "Слишком много попыток. Подождите немного.",
|
||||||
"invalidCredentials": "Неверные данные для входа."
|
"invalidCredentials": "Неверные данные для входа.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Установить Oikos",
|
"title": "Установить Oikos",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "Logga in",
|
"loginButton": "Logga in",
|
||||||
"loggingIn": "Loggar in...",
|
"loggingIn": "Loggar in...",
|
||||||
"tooManyAttempts": "För många försök. Vänta ett ögonblick.",
|
"tooManyAttempts": "För många försök. Vänta ett ögonblick.",
|
||||||
"invalidCredentials": "Ogiltiga användaruppgifter."
|
"invalidCredentials": "Ogiltiga användaruppgifter.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Installera Oikos",
|
"title": "Installera Oikos",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "Giriş yap",
|
"loginButton": "Giriş yap",
|
||||||
"loggingIn": "Giriş yapılıyor…",
|
"loggingIn": "Giriş yapılıyor…",
|
||||||
"tooManyAttempts": "Çok fazla deneme. Lütfen bir süre bekleyin.",
|
"tooManyAttempts": "Çok fazla deneme. Lütfen bir süre bekleyin.",
|
||||||
"invalidCredentials": "Geçersiz kimlik bilgileri."
|
"invalidCredentials": "Geçersiz kimlik bilgileri.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Oikos'u Yükle",
|
"title": "Oikos'u Yükle",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "Увійти",
|
"loginButton": "Увійти",
|
||||||
"loggingIn": "Вхід…",
|
"loggingIn": "Вхід…",
|
||||||
"tooManyAttempts": "Забагато спроб. Будь ласка, зачекайте.",
|
"tooManyAttempts": "Забагато спроб. Будь ласка, зачекайте.",
|
||||||
"invalidCredentials": "Невірні облікові дані."
|
"invalidCredentials": "Невірні облікові дані.",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "Встановити Oikos",
|
"title": "Встановити Oikos",
|
||||||
|
|||||||
@@ -567,7 +567,8 @@
|
|||||||
"loginButton": "登录",
|
"loginButton": "登录",
|
||||||
"loggingIn": "登录中…",
|
"loggingIn": "登录中…",
|
||||||
"tooManyAttempts": "尝试次数过多,请稍后再试。",
|
"tooManyAttempts": "尝试次数过多,请稍后再试。",
|
||||||
"invalidCredentials": "用户名或密码错误。"
|
"invalidCredentials": "用户名或密码错误。",
|
||||||
|
"version": "v{{version}}"
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"title": "安装 Oikos",
|
"title": "安装 Oikos",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import { auth } from '/api.js';
|
import { auth } from '/api.js';
|
||||||
import { t } from '/i18n.js';
|
import { t } from '/i18n.js';
|
||||||
|
|
||||||
|
const VERSION_URL = '/api/v1/version';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rendert die Login-Seite in den gegebenen Container.
|
* Rendert die Login-Seite in den gegebenen Container.
|
||||||
* @param {HTMLElement} container
|
* @param {HTMLElement} container
|
||||||
@@ -56,12 +58,19 @@ export async function render(container) {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="login-version" id="login-version"></p>
|
||||||
</main>
|
</main>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const form = container.querySelector('#login-form');
|
const form = container.querySelector('#login-form');
|
||||||
const errorEl = container.querySelector('#login-error');
|
const errorEl = container.querySelector('#login-error');
|
||||||
const submitBtn = container.querySelector('#login-btn');
|
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) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
|||||||
} catch {
|
} catch {
|
||||||
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
|
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
|
||||||
isNavigating = false;
|
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');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,3 +55,11 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
margin-bottom: var(--space-4);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import express from 'express';
|
|||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { createLogger } from './logger.js';
|
import { createLogger } from './logger.js';
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import { router as authRouter, sessionMiddleware, requireAuth } from './auth.js';
|
import { router as authRouter, sessionMiddleware, requireAuth } from './auth.js';
|
||||||
@@ -32,6 +33,10 @@ const log = createLogger('Server');
|
|||||||
const logSync = createLogger('Sync');
|
const logSync = createLogger('Sync');
|
||||||
const logOikos = createLogger('Oikos');
|
const logOikos = createLogger('Oikos');
|
||||||
|
|
||||||
|
const { version: APP_VERSION } = JSON.parse(
|
||||||
|
readFileSync(new URL('../package.json', import.meta.url), 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
@@ -155,6 +160,11 @@ app.use('/api/', apiLimiter);
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
app.use('/api/v1/auth', authRouter);
|
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
|
// Alle weiteren API-Routen erfordern Authentifizierung + CSRF-Schutz
|
||||||
app.use('/api/v1', requireAuth);
|
app.use('/api/v1', requireAuth);
|
||||||
app.use('/api/v1', csrfMiddleware);
|
app.use('/api/v1', csrfMiddleware);
|
||||||
|
|||||||
Reference in New Issue
Block a user