diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8c0a46..5ce537a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,6 +199,22 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # Create the release if a tag was pushed without one (e.g. tag + # pushed via `git push --tags` rather than `gh release create`). + if ! gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + gh release create "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${GITHUB_REF_NAME}" \ + --generate-notes + fi + # Create the release if a tag was pushed without one (e.g. tag + # pushed via `git push --tags` rather than `gh release create`). + if ! gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + gh release create "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${GITHUB_REF_NAME}" \ + --generate-notes + fi gh release upload "${GITHUB_REF_NAME}" dist/* \ --repo "${GITHUB_REPOSITORY}" \ --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md index 424e07d..c53eb62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.1] — 2026-04-29 + +### Fixed + +- Idle browser tabs no longer get logged out when the access JWT expires. The frontend now single-flights `POST /api/v1/auth/refresh` so simultaneous 401s on tab wake (config, calls list, listener TG selection, WebSocket reauth, scheduled refresh, etc.) coalesce onto one network request. Previously, parallel refresh attempts presented the same single-use refresh token; the server treated the second attempt as a replay and revoked the entire token family, forcing a re-login despite the 30-day refresh cookie. +- Release workflow now creates the GitHub Release when a tag is pushed without one, instead of failing with `release not found` when uploading binaries. Future `git push --tags` releases publish artifacts without a manual `gh release create` step. + ## [1.3.0] — 2026-04-29 ### Added diff --git a/frontend/src/app/api.ts b/frontend/src/app/api.ts index 87f394b..4c682e6 100644 --- a/frontend/src/app/api.ts +++ b/frontend/src/app/api.ts @@ -27,40 +27,74 @@ const rawBaseQuery = fetchBaseQuery({ * Wrapper that intercepts 401 responses and attempts a silent token refresh. * If the refresh succeeds, the original request is retried with the new token. * If the refresh fails, credentials are cleared (user sees login screen). + * + * Refresh is single-flighted: if multiple requests 401 simultaneously (typical + * on tab wake / network resume), or multiple call sites trigger + * POST /auth/refresh in parallel (RTK Query 401 handler + scheduled refresh + * + WS reconnect + audio recovery), only one network refresh actually goes + * out and the rest await the same promise. Without this, parallel refresh + * attempts present the same single-use refresh token; the server detects + * "replay" on the loser and revokes the entire token family — forcing + * re-login even though the refresh cookie is nowhere near its TTL. */ +type RefreshQueryResult = Awaited>; +let refreshInFlight: Promise | null = null; + +function runRefresh( + storeApi: Parameters[1], + extraOptions: Parameters[2], +): Promise { + if (!refreshInFlight) { + refreshInFlight = (async () => { + try { + const refreshResult = await rawBaseQuery( + { url: "/auth/refresh", method: "POST" }, + storeApi, + extraOptions, + ); + if (refreshResult.data) { + const refreshData = refreshResult.data as RefreshResponse; + storeApi.dispatch({ + type: "auth/setCredentials", + payload: { + token: refreshData.token, + role: refreshData.user.role, + username: refreshData.user.username, + passwordNeedChange: false, + }, + }); + } + return refreshResult; + } finally { + refreshInFlight = null; + } + })(); + } + return refreshInFlight; +} + const baseQueryWithRefresh: BaseQueryFn< string | FetchArgs, unknown, FetchBaseQueryError > = async (args, storeApi, extraOptions) => { - let result = await rawBaseQuery(args, storeApi, extraOptions); + const url = typeof args === "string" ? args : args.url; - if (result.error && result.error.status === 401) { - // Don't try to refresh if the failing request IS the refresh endpoint. - const url = typeof args === "string" ? args : args.url; - if (url === "/auth/refresh") { + // Coalesce direct calls to /auth/refresh (useAuthInit, useTokenRefresh, + // WS reauth) onto the same in-flight promise as 401-triggered refreshes. + if (url === "/auth/refresh") { + const result = await runRefresh(storeApi, extraOptions); + if (result.error && result.error.status === 401) { storeApi.dispatch({ type: "auth/clearCredentials" }); - return result; } + return result; + } - // Attempt silent refresh. - const refreshResult = await rawBaseQuery( - { url: "/auth/refresh", method: "POST" }, - storeApi, - extraOptions, - ); + let result = await rawBaseQuery(args, storeApi, extraOptions); + if (result.error && result.error.status === 401) { + const refreshResult = await runRefresh(storeApi, extraOptions); if (refreshResult.data) { - const refreshData = refreshResult.data as RefreshResponse; - storeApi.dispatch({ - type: "auth/setCredentials", - payload: { - token: refreshData.token, - role: refreshData.user.role, - username: refreshData.user.username, - passwordNeedChange: false, - }, - }); // Retry original request with new token. result = await rawBaseQuery(args, storeApi, extraOptions); } else {