fix(auth): skip auth:expired dispatch for 401 on /auth/login (#69)

On Safari/iOS PWA cold start or after cookie clear, logging in with wrong
credentials triggered auth:expired, re-rendering the login page and losing
the error message. The login endpoint returns 401 for invalid credentials,
not for session expiry, so apiFetch must not fire auth:expired in that path.

Resolves #68

Co-authored-by: Ulas Kalayci <ulas.kalayci@googlemail.com>
This commit is contained in:
ulsklyc
2026-04-20 21:37:29 +02:00
committed by GitHub
parent 554024b67c
commit 9ad1165d48
4 changed files with 127 additions and 5 deletions
+5
View File
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.20.34] - 2026-04-20
### Fixed
- Login on Safari/iOS PWA no longer loops back to the login screen when credentials are wrong: `apiFetch` no longer dispatches `auth:expired` for 401 responses from `/auth/login` (where 401 means invalid credentials, not expired session) — the error message is now shown correctly instead of the form being silently re-rendered
## [0.20.33] - 2026-04-20 ## [0.20.33] - 2026-04-20
### Fixed ### Fixed
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.20.33", "version": "0.20.34",
"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",
@@ -21,7 +21,8 @@
"test:ux-utils": "node test-ux-utils.js", "test:ux-utils": "node test-ux-utils.js",
"test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js", "test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js",
"test:reminders": "node --experimental-sqlite test-reminders.js", "test:reminders": "node --experimental-sqlite test-reminders.js",
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders" "test:api": "node test-api.js",
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api"
}, },
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
+7 -3
View File
@@ -44,9 +44,13 @@ async function apiFetch(path, options = {}, _retried = false) {
}); });
if (response.status === 401) { if (response.status === 401) {
// Session abgelaufen → zur Login-Seite // Beim Login-Endpunkt bedeutet 401 "falsche Zugangsdaten", nicht "Session abgelaufen".
window.dispatchEvent(new CustomEvent('auth:expired')); // auth:expired würde die Login-Seite neu rendern und die Fehlermeldung verwerfen.
throw new Error('Sitzung abgelaufen.'); if (path !== '/auth/login') {
window.dispatchEvent(new CustomEvent('auth:expired'));
throw new Error('Sitzung abgelaufen.');
}
// Für /auth/login: fall-through zum generischen !response.ok-Handler unten.
} }
// CSRF-Token-Desync (haeufig nach iOS-PWA-Resume): einmal GET /auth/me // CSRF-Token-Desync (haeufig nach iOS-PWA-Resume): einmal GET /auth/me
+112
View File
@@ -0,0 +1,112 @@
/**
* Tests: API-Client (public/api.js)
* Fokus: CSRF-Token-Handling, auth:expired-Dispatch-Verhalten
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
// Browser-Globals für Node-Kontext simulieren
global.CustomEvent = class CustomEvent {
constructor(type, init) { this.type = type; this.detail = init?.detail; }
};
let dispatchedEvents = [];
global.window = {
dispatchEvent(e) { dispatchedEvents.push(e); },
addEventListener() {},
};
global.document = { cookie: '' };
// fetch-Mock: wird pro Test überschrieben
let _mockFetch = null;
global.fetch = (...args) => _mockFetch(...args);
function mockResponse(status, body = {}, headers = {}) {
return Promise.resolve({
status,
ok: status >= 200 && status < 300,
headers: {
get(name) { return headers[name] ?? null; },
},
json: () => Promise.resolve(body),
});
}
const { api, auth, ApiError } = await import('./public/api.js');
function setup() {
dispatchedEvents = [];
document.cookie = '';
}
// ─── 401 auf Login-Endpunkt ──────────────────────────────────────────────────
test('auth.login: 401 feuert kein auth:expired', async () => {
setup();
_mockFetch = () => mockResponse(401, { error: 'Ungültige Anmeldedaten.', code: 401 });
await assert.rejects(
() => auth.login('user', 'wrong'),
(err) => {
assert.equal(err.constructor.name, 'ApiError');
assert.equal(err.status, 401);
return true;
},
);
const expired = dispatchedEvents.filter((e) => e.type === 'auth:expired');
assert.equal(expired.length, 0, 'auth:expired darf bei Login-401 nicht gefeuert werden');
});
test('auth.login: 401 wirft ApiError mit status 401', async () => {
setup();
_mockFetch = () => mockResponse(401, { error: 'Ungültige Anmeldedaten.', code: 401 });
let thrownErr;
try {
await auth.login('user', 'wrong');
} catch (e) {
thrownErr = e;
}
assert.ok(thrownErr instanceof ApiError, 'Muss ApiError sein');
assert.equal(thrownErr.status, 401);
});
// ─── 401 auf anderen Endpunkten ─────────────────────────────────────────────
test('api.get: 401 auf geschütztem Endpunkt feuert auth:expired', async () => {
setup();
_mockFetch = () => mockResponse(401, { error: 'Nicht authentifiziert.', code: 401 });
await assert.rejects(() => api.get('/tasks'));
const expired = dispatchedEvents.filter((e) => e.type === 'auth:expired');
assert.equal(expired.length, 1, 'auth:expired muss bei 401 auf geschütztem Endpunkt gefeuert werden');
});
test('api.post: 401 auf Logout-Endpunkt feuert auth:expired', async () => {
setup();
_mockFetch = () => mockResponse(401, { error: 'Nicht authentifiziert.', code: 401 });
await assert.rejects(() => api.post('/auth/logout', {}));
const expired = dispatchedEvents.filter((e) => e.type === 'auth:expired');
assert.equal(expired.length, 1, 'auth:expired muss bei 401 auf /auth/logout gefeuert werden');
});
// ─── Erfolgreicher Login ─────────────────────────────────────────────────────
test('auth.login: Erfolg speichert csrfToken aus Body', async () => {
setup();
const token = 'abc123def456';
_mockFetch = () => mockResponse(200, {
user: { id: 1, username: 'admin' },
csrfToken: token,
});
const result = await auth.login('admin', 'password');
assert.equal(result.user.username, 'admin');
assert.equal(result.csrfToken, token);
assert.equal(dispatchedEvents.length, 0, 'Kein Event bei erfolgreichem Login');
});