diff --git a/docs/changelog.md b/docs/changelog.md
index 569e30e29..c361309d1 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,32 @@
# Changelog
+## paperless-ngx 2.20.12
+
+### Security
+
+- Resolve [GHSA-96jx-fj7m-qh6x](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-96jx-fj7m-qh6x)
+
+### Bug Fixes
+
+- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
+- Fix: don't try to usermod/groupmod when non-root + update docs (#12365) [@stumpylog](https://github.com/stumpylog) ([#12391](https://github.com/paperless-ngx/paperless-ngx/pull/12391))
+- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
+- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
+- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
+- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
+
+### All App Changes
+
+
+5 changes
+
+- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
+- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
+- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
+- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
+- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
+
+
## paperless-ngx 2.20.11
### Security
diff --git a/pyproject.toml b/pyproject.toml
index 6bbdf0f48..f2a20ac47 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
-version = "2.20.11"
+version = "2.20.13"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/src-ui/package.json b/src-ui/package.json
index 95602b934..273a32e04 100644
--- a/src-ui/package.json
+++ b/src-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
- "version": "2.20.11",
+ "version": "2.20.13",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
diff --git a/src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts b/src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts
new file mode 100644
index 000000000..7e9f156b6
--- /dev/null
+++ b/src-ui/src/app/interceptors/auth-expiry.interceptor.spec.ts
@@ -0,0 +1,122 @@
+import {
+ HttpErrorResponse,
+ HttpHandlerFn,
+ HttpRequest,
+} from '@angular/common/http'
+import { throwError } from 'rxjs'
+import * as navUtils from '../utils/navigation'
+import { createAuthExpiryInterceptor } from './auth-expiry.interceptor'
+
+describe('withAuthExpiryInterceptor', () => {
+ let interceptor: ReturnType
+ let dateNowSpy: jest.SpiedFunction
+
+ beforeEach(() => {
+ interceptor = createAuthExpiryInterceptor()
+ dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000)
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ it('reloads when an API request returns 401', () => {
+ const reloadSpy = jest
+ .spyOn(navUtils, 'locationReload')
+ .mockImplementation(() => {})
+
+ interceptor(
+ new HttpRequest('GET', '/api/documents/'),
+ failingHandler('/api/documents/', 401)
+ ).subscribe({
+ error: () => undefined,
+ })
+
+ expect(reloadSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not reload for non-401 errors', () => {
+ const reloadSpy = jest
+ .spyOn(navUtils, 'locationReload')
+ .mockImplementation(() => {})
+
+ interceptor(
+ new HttpRequest('GET', '/api/documents/'),
+ failingHandler('/api/documents/', 500)
+ ).subscribe({
+ error: () => undefined,
+ })
+
+ expect(reloadSpy).not.toHaveBeenCalled()
+ })
+
+ it('does not reload for non-api 401 responses', () => {
+ const reloadSpy = jest
+ .spyOn(navUtils, 'locationReload')
+ .mockImplementation(() => {})
+
+ interceptor(
+ new HttpRequest('GET', '/accounts/profile/'),
+ failingHandler('/accounts/profile/', 401)
+ ).subscribe({
+ error: () => undefined,
+ })
+
+ expect(reloadSpy).not.toHaveBeenCalled()
+ })
+
+ it('reloads only once even with multiple API 401 responses', () => {
+ const reloadSpy = jest
+ .spyOn(navUtils, 'locationReload')
+ .mockImplementation(() => {})
+
+ const request = new HttpRequest('GET', '/api/documents/')
+ const handler = failingHandler('/api/documents/', 401)
+
+ interceptor(request, handler).subscribe({
+ error: () => undefined,
+ })
+ interceptor(request, handler).subscribe({
+ error: () => undefined,
+ })
+
+ expect(reloadSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('retries reload after cooldown for repeated API 401 responses', () => {
+ const reloadSpy = jest
+ .spyOn(navUtils, 'locationReload')
+ .mockImplementation(() => {})
+
+ dateNowSpy
+ .mockReturnValueOnce(1000)
+ .mockReturnValueOnce(2500)
+ .mockReturnValueOnce(3501)
+
+ const request = new HttpRequest('GET', '/api/documents/')
+ const handler = failingHandler('/api/documents/', 401)
+
+ interceptor(request, handler).subscribe({
+ error: () => undefined,
+ })
+ interceptor(request, handler).subscribe({
+ error: () => undefined,
+ })
+ interceptor(request, handler).subscribe({
+ error: () => undefined,
+ })
+
+ expect(reloadSpy).toHaveBeenCalledTimes(2)
+ })
+})
+
+function failingHandler(url: string, status: number): HttpHandlerFn {
+ return (_request) =>
+ throwError(
+ () =>
+ new HttpErrorResponse({
+ status,
+ url,
+ })
+ )
+}
diff --git a/src-ui/src/app/interceptors/auth-expiry.interceptor.ts b/src-ui/src/app/interceptors/auth-expiry.interceptor.ts
new file mode 100644
index 000000000..814cceed5
--- /dev/null
+++ b/src-ui/src/app/interceptors/auth-expiry.interceptor.ts
@@ -0,0 +1,37 @@
+import {
+ HttpErrorResponse,
+ HttpEvent,
+ HttpHandlerFn,
+ HttpInterceptorFn,
+ HttpRequest,
+} from '@angular/common/http'
+import { catchError, Observable, throwError } from 'rxjs'
+import { locationReload } from '../utils/navigation'
+
+export const createAuthExpiryInterceptor = (): HttpInterceptorFn => {
+ let lastReloadAttempt = Number.NEGATIVE_INFINITY
+
+ return (
+ request: HttpRequest,
+ next: HttpHandlerFn
+ ): Observable> =>
+ next(request).pipe(
+ catchError((error: unknown) => {
+ if (
+ error instanceof HttpErrorResponse &&
+ error.status === 401 &&
+ request.url.includes('/api/')
+ ) {
+ const now = Date.now()
+ if (now - lastReloadAttempt >= 2000) {
+ lastReloadAttempt = now
+ locationReload()
+ }
+ }
+
+ return throwError(() => error)
+ })
+ )
+}
+
+export const withAuthExpiryInterceptor = createAuthExpiryInterceptor()
diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts
index 1e8adbc22..4b66010c6 100644
--- a/src-ui/src/environments/environment.prod.ts
+++ b/src-ui/src/environments/environment.prod.ts
@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: 'Paperless-ngx',
tag: 'prod',
- version: '2.20.11',
+ version: '2.20.13',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts
index bd1e6ebbb..c5c94d3ff 100644
--- a/src-ui/src/main.ts
+++ b/src-ui/src/main.ts
@@ -154,6 +154,7 @@ import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard'
import { PermissionsGuard } from './app/guards/permissions.guard'
import { withApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
+import { withAuthExpiryInterceptor } from './app/interceptors/auth-expiry.interceptor'
import { withCsrfInterceptor } from './app/interceptors/csrf.interceptor'
import { DocumentTitlePipe } from './app/pipes/document-title.pipe'
import { FilterPipe } from './app/pipes/filter.pipe'
@@ -399,7 +400,11 @@ bootstrapApplication(AppComponent, {
StoragePathNamePipe,
provideHttpClient(
withInterceptorsFromDi(),
- withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]),
+ withInterceptors([
+ withCsrfInterceptor,
+ withApiVersionInterceptor,
+ withAuthExpiryInterceptor,
+ ]),
withFetch()
),
provideUiTour({
diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss
index c60284c8a..828fc1a15 100644
--- a/src-ui/src/theme.scss
+++ b/src-ui/src/theme.scss
@@ -154,6 +154,11 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,