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).