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:
@@ -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
@@ -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",
|
||||||
|
|||||||
+5
-1
@@ -44,10 +44,14 @@ 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".
|
||||||
|
// auth:expired würde die Login-Seite neu rendern und die Fehlermeldung verwerfen.
|
||||||
|
if (path !== '/auth/login') {
|
||||||
window.dispatchEvent(new CustomEvent('auth:expired'));
|
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||||||
throw new Error('Sitzung abgelaufen.');
|
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
|
||||||
// ausfuehren um den CSRF-Token zu erneuern, dann den Request wiederholen.
|
// ausfuehren um den CSRF-Token zu erneuern, dann den Request wiederholen.
|
||||||
|
|||||||
+112
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user