From 0865659ab3987a2b1b429acce34bbb1163d28336 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 1 Jul 2026 20:11:07 +0200 Subject: [PATCH] fix(auth): keep session on a wrong-current-password 401 The change-password endpoint returns 401 for a wrong *current* password, the same status the global on401 hook uses to detect an expired session. The hook fired first and cleared session.user, so the account form's "still signed in?" guard always saw null and bounced the user to /login instead of surfacing the error inline. Add a per-call `suppressOn401` option to `request`; changePassword opts in, so a 401 there stays inline and the session is left intact. Co-Authored-By: Claude Opus 4.8 --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/package.json | 2 +- frontend/src/lib/api/auth.ts | 17 ++++++++++++----- frontend/src/lib/api/client.test.ts | 19 +++++++++++++++++++ frontend/src/lib/api/client.ts | 21 +++++++++++++++++++-- 6 files changed, 53 insertions(+), 10 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a2ea29f..9974a84 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.93.1" +version = "0.93.2" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e043185..318a5e7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.93.1" +version = "0.93.2" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index b162a42..873b41a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.93.1", + "version": "0.93.2", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index 5f65317..9d42871 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -58,11 +58,18 @@ export type ChangePassword = { * password. */ export async function changePassword(input: ChangePassword): Promise { - await request('/v1/auth/me/password', { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(input) - }); + await request( + '/v1/auth/me/password', + { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input) + }, + // A 401 here means the *current* password was wrong, not that the + // session expired — don't let the global hook clear the user and + // bounce them to /login; the form surfaces the error inline. + { suppressOn401: true } + ); } /** diff --git a/frontend/src/lib/api/client.test.ts b/frontend/src/lib/api/client.test.ts index 417de75..dc5bc3f 100644 --- a/frontend/src/lib/api/client.test.ts +++ b/frontend/src/lib/api/client.test.ts @@ -103,6 +103,25 @@ describe('on401 hook', () => { expect(hook).toHaveBeenCalledTimes(1); }); + it('does not invoke the hook on a 401 when suppressOn401 is set', async () => { + // The change-password endpoint returns 401 for a *wrong current + // password*, not an expired session — clearing the cached user + // there would spuriously log the (still-authenticated) user out. + // Such calls opt out of the hook while still throwing the 401. + const hook = vi.fn(); + setOn401Hook(hook); + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: 'unauthenticated', message: 'wrong password' } }), + { status: 401, headers: { 'content-type': 'application/json' } } + ) + ); + await expect( + request('/v1/auth/me/password', { method: 'PATCH' }, { suppressOn401: true }) + ).rejects.toMatchObject({ status: 401, code: 'unauthenticated' }); + expect(hook).not.toHaveBeenCalled(); + }); + it('does not invoke the hook on non-401 errors', async () => { const hook = vi.fn(); setOn401Hook(hook); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 179f79d..22df9d3 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -54,7 +54,24 @@ export function setOn401Hook(handler: (() => void) | null): void { on401Hook = handler; } -export async function request(path: string, init?: RequestInit): Promise { +/** Per-call knobs that don't belong on the native `RequestInit`. */ +export type RequestOptions = { + /** + * Skip the module-level 401 hook for this call. Used by endpoints + * where a 401 does *not* mean "session expired" — e.g. the + * change-password endpoint returns 401 for a wrong *current* + * password while the caller is still fully authenticated. Firing + * the hook there would clear the cached user and bounce them to + * /login instead of surfacing the error inline. + */ + suppressOn401?: boolean; +}; + +export async function request( + path: string, + init?: RequestInit, + opts?: RequestOptions +): Promise { // Forward credentials (session cookie) explicitly so cross-origin // deployments — those configured via CORS_ALLOWED_ORIGINS — keep // working. For same-origin requests this is a no-op compared to the @@ -87,7 +104,7 @@ export async function request(path: string, init?: RequestInit): Promise { } catch { // Body wasn't parseable; keep the http_error fallback. } - if (res.status === 401 && on401Hook) { + if (res.status === 401 && on401Hook && !opts?.suppressOn401) { // Fire before throwing so the session store updates even // if the caller swallows the ApiError (e.g. the *OrEmpty // wrappers used by guest-rendering pages).