Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 56 additions & 22 deletions frontend/src/app/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof rawBaseQuery>>;
let refreshInFlight: Promise<RefreshQueryResult> | null = null;

function runRefresh(
storeApi: Parameters<typeof rawBaseQuery>[1],
extraOptions: Parameters<typeof rawBaseQuery>[2],
): Promise<RefreshQueryResult> {
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 {
Expand Down
Loading