From 54bf834cca652ba71b76fc40e9b8b1afbfd80318 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 4 Apr 2026 14:58:57 +0000
Subject: [PATCH 01/13] Initial plan
From 352417058bd7e8e6f9c3fc4e46d2d01fd3bbe5d6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 4 Apr 2026 15:04:04 +0000
Subject: [PATCH 02/13] Warn user when credentials have expired in multiuser
mode
Agent-Logs-Url: https://github.com/lstein/InvokeAI/sessions/f0947cda-b15c-475d-b7f4-2d553bdf2cd6
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
invokeai/frontend/web/public/locales/en.json | 3 ++-
.../features/auth/components/LoginPage.tsx | 11 ++++++--
.../auth/components/ProtectedRoute.tsx | 6 ++---
.../web/src/features/auth/store/authSlice.ts | 18 +++++++++++--
.../frontend/web/src/services/api/index.ts | 27 ++++++++++++++-----
5 files changed, 50 insertions(+), 15 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 0c72fc95107..b6a8cc36e25 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -25,7 +25,8 @@
"rememberMe": "Remember me for 7 days",
"signIn": "Sign In",
"signingIn": "Signing in...",
- "loginFailed": "Login failed. Please check your credentials."
+ "loginFailed": "Login failed. Please check your credentials.",
+ "sessionExpired": "Your credentials have expired. Please log in again to resume."
},
"setup": {
"title": "Welcome to InvokeAI",
diff --git a/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx b/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx
index ddc813163de..b4f01d5878b 100644
--- a/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx
+++ b/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx
@@ -13,8 +13,8 @@ import {
Text,
VStack,
} from '@invoke-ai/ui-library';
-import { useAppDispatch } from 'app/store/storeHooks';
-import { setCredentials } from 'features/auth/store/authSlice';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { selectSessionExpired, setCredentials } from 'features/auth/store/authSlice';
import type { ChangeEvent, FormEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -29,6 +29,7 @@ export const LoginPage = memo(() => {
const [rememberMe, setRememberMe] = useState(true);
const [login, { isLoading, error }] = useLoginMutation();
const dispatch = useAppDispatch();
+ const sessionExpired = useAppSelector(selectSessionExpired);
const { data: setupStatus, isLoading: isLoadingSetup } = useGetSetupStatusQuery();
// Redirect to app if multiuser mode is disabled
@@ -114,6 +115,12 @@ export const LoginPage = memo(() => {
{t('auth.login.title')}
+ {sessionExpired && (
+
+ {t('auth.login.sessionExpired')}
+
+ )}
+
{t('auth.login.email')}
{
- // If we have a token but fetching user failed, token is invalid - logout
+ // If we have a token but fetching user failed, token is invalid/expired - logout
if (userError && isAuthenticated) {
- dispatch(logout());
+ dispatch(sessionExpiredLogout());
navigate('/login', { replace: true });
}
}, [userError, isAuthenticated, dispatch, navigate]);
diff --git a/invokeai/frontend/web/src/features/auth/store/authSlice.ts b/invokeai/frontend/web/src/features/auth/store/authSlice.ts
index 6ac65ef03ce..d933c57ed34 100644
--- a/invokeai/frontend/web/src/features/auth/store/authSlice.ts
+++ b/invokeai/frontend/web/src/features/auth/store/authSlice.ts
@@ -16,6 +16,7 @@ const zAuthState = z.object({
token: z.string().nullable(),
user: zUser.nullable(),
isLoading: z.boolean(),
+ sessionExpired: z.boolean(),
});
type User = z.infer;
@@ -34,6 +35,7 @@ const initialState: AuthState = {
token: getStoredAuthToken(),
user: null,
isLoading: false,
+ sessionExpired: false,
};
const getInitialAuthState = (): AuthState => initialState;
@@ -46,6 +48,7 @@ const authSlice = createSlice({
state.token = action.payload.token;
state.user = action.payload.user;
state.isAuthenticated = true;
+ state.sessionExpired = false;
if (typeof window !== 'undefined' && window.localStorage) {
localStorage.setItem('auth_token', action.payload.token);
}
@@ -54,6 +57,16 @@ const authSlice = createSlice({
state.token = null;
state.user = null;
state.isAuthenticated = false;
+ state.sessionExpired = false;
+ if (typeof window !== 'undefined' && window.localStorage) {
+ localStorage.removeItem('auth_token');
+ }
+ },
+ sessionExpiredLogout: (state) => {
+ state.token = null;
+ state.user = null;
+ state.isAuthenticated = false;
+ state.sessionExpired = true;
if (typeof window !== 'undefined' && window.localStorage) {
localStorage.removeItem('auth_token');
}
@@ -64,7 +77,7 @@ const authSlice = createSlice({
},
});
-export const { setCredentials, logout, setLoading } = authSlice.actions;
+export const { setCredentials, logout, sessionExpiredLogout, setLoading } = authSlice.actions;
export const authSliceConfig: SliceConfig = {
slice: authSlice,
@@ -73,7 +86,7 @@ export const authSliceConfig: SliceConfig = {
persistConfig: {
migrate: () => getInitialAuthState(),
// Don't persist auth state - token is stored in localStorage
- persistDenylist: ['isAuthenticated', 'token', 'user', 'isLoading'],
+ persistDenylist: ['isAuthenticated', 'token', 'user', 'isLoading', 'sessionExpired'],
},
};
@@ -81,3 +94,4 @@ export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.
export const selectCurrentUser = (state: { auth: AuthState }) => state.auth.user;
export const selectAuthToken = (state: { auth: AuthState }) => state.auth.token;
export const selectIsAuthLoading = (state: { auth: AuthState }) => state.auth.isLoading;
+export const selectSessionExpired = (state: { auth: AuthState }) => state.auth.sessionExpired;
diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts
index 5be1aa2a67f..4b90c5542d9 100644
--- a/invokeai/frontend/web/src/services/api/index.ts
+++ b/invokeai/frontend/web/src/services/api/index.ts
@@ -7,6 +7,7 @@ import type {
TagDescription,
} from '@reduxjs/toolkit/query/react';
import { buildCreateApi, coreModule, fetchBaseQuery, reactHooksModule } from '@reduxjs/toolkit/query/react';
+import { sessionExpiredLogout } from 'features/auth/store/authSlice';
import queryString from 'query-string';
import stableHash from 'stable-hash';
@@ -68,21 +69,26 @@ export const getBaseUrl = (): string => {
return window.location.origin;
};
-const dynamicBaseQuery: BaseQueryFn = (args, api, extraOptions) => {
+const dynamicBaseQuery: BaseQueryFn = async (
+ args,
+ api,
+ extraOptions
+) => {
const isOpenAPIRequest =
(args instanceof Object && args.url.includes('openapi.json')) ||
(typeof args === 'string' && args.includes('openapi.json'));
+ const isAuthEndpoint =
+ (args instanceof Object &&
+ typeof args.url === 'string' &&
+ (args.url.includes('/auth/login') || args.url.includes('/auth/setup'))) ||
+ (typeof args === 'string' && (args.includes('/auth/login') || args.includes('/auth/setup')));
+
const fetchBaseQueryArgs: FetchBaseQueryArgs = {
baseUrl: getBaseUrl(),
prepareHeaders: (headers) => {
// Add auth token to all requests except setup and login
const token = localStorage.getItem('auth_token');
- const isAuthEndpoint =
- (args instanceof Object &&
- typeof args.url === 'string' &&
- (args.url.includes('/auth/login') || args.url.includes('/auth/setup'))) ||
- (typeof args === 'string' && (args.includes('/auth/login') || args.includes('/auth/setup')));
if (token && !isAuthEndpoint) {
headers.set('Authorization', `Bearer ${token}`);
@@ -98,7 +104,14 @@ const dynamicBaseQuery: BaseQueryFn
Date: Sat, 4 Apr 2026 15:05:55 +0000
Subject: [PATCH 03/13] Address code review: avoid multiple localStorage reads
in base query
Agent-Logs-Url: https://github.com/lstein/InvokeAI/sessions/f0947cda-b15c-475d-b7f4-2d553bdf2cd6
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
---
invokeai/frontend/web/src/services/api/index.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts
index 4b90c5542d9..26a8eee0602 100644
--- a/invokeai/frontend/web/src/services/api/index.ts
+++ b/invokeai/frontend/web/src/services/api/index.ts
@@ -84,12 +84,12 @@ const dynamicBaseQuery: BaseQueryFn {
// Add auth token to all requests except setup and login
- const token = localStorage.getItem('auth_token');
-
if (token && !isAuthEndpoint) {
headers.set('Authorization', `Bearer ${token}`);
}
@@ -107,7 +107,7 @@ const dynamicBaseQuery: BaseQueryFn
Date: Sat, 4 Apr 2026 13:29:39 -0400
Subject: [PATCH 04/13] bugfix(multiuser): ask user to log back in when
authentication token expires
---
.../auth/components/ProtectedRoute.tsx | 27 +++++++++++++++++++
.../frontend/web/src/services/api/index.ts | 4 ++-
2 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx
index 2901fe374cc..60cb05761e4 100644
--- a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx
+++ b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx
@@ -40,6 +40,33 @@ export const ProtectedRoute = memo(({ children, requireAdmin = false }: PropsWit
}
}, [userError, isAuthenticated, dispatch, navigate]);
+ // Detect when auth_token is removed from localStorage (e.g. by another tab,
+ // browser devtools, or token expiry cleanup). The 'storage' event fires when
+ // localStorage is modified by another context; we also poll periodically to
+ // catch same-tab deletions (which don't trigger the storage event).
+ useEffect(() => {
+ if (!multiuserEnabled || !isAuthenticated) {
+ return;
+ }
+
+ const checkToken = () => {
+ if (!localStorage.getItem('auth_token') && isAuthenticated) {
+ dispatch(sessionExpiredLogout());
+ navigate('/login', { replace: true });
+ }
+ };
+
+ // Listen for cross-tab localStorage changes
+ window.addEventListener('storage', checkToken);
+ // Poll for same-tab deletions (e.g. browser console)
+ const interval = setInterval(checkToken, 5000);
+
+ return () => {
+ window.removeEventListener('storage', checkToken);
+ clearInterval(interval);
+ };
+ }, [multiuserEnabled, isAuthenticated, dispatch, navigate]);
+
useEffect(() => {
// If we successfully fetched user data, update auth state
if (currentUser && token && !user) {
diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts
index 26a8eee0602..84be23bb12e 100644
--- a/invokeai/frontend/web/src/services/api/index.ts
+++ b/invokeai/frontend/web/src/services/api/index.ts
@@ -106,7 +106,9 @@ const dynamicBaseQuery: BaseQueryFn
Date: Sat, 4 Apr 2026 13:58:33 -0400
Subject: [PATCH 05/13] feat: sliding window session expiry with token refresh
Backend:
- SlidingWindowTokenMiddleware refreshes JWT on each mutating request
(POST/PUT/PATCH/DELETE), returning a new token in X-Refreshed-Token
response header. GET requests don't refresh (they're often background
fetches that shouldn't reset the inactivity timer).
- CORS expose_headers updated to allow X-Refreshed-Token.
Frontend:
- dynamicBaseQuery picks up X-Refreshed-Token from responses and
updates localStorage so subsequent requests use the fresh expiry.
- 401 handler only triggers sessionExpiredLogout when a token was
actually sent (not for unauthenticated background requests).
- ProtectedRoute polls localStorage every 5s and listens for storage
events to detect token removal (e.g. manual deletion, other tabs).
Result: session expires after TOKEN_EXPIRATION_NORMAL (1 day) of
inactivity, not a fixed time after login. Any user-initiated action
resets the clock.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
invokeai/app/api_app.py | 54 +++++++++++++++++++
.../frontend/web/src/services/api/index.ts | 9 ++++
2 files changed, 63 insertions(+)
diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py
index 49894dba3cc..c94560850b5 100644
--- a/invokeai/app/api_app.py
+++ b/invokeai/app/api_app.py
@@ -79,6 +79,58 @@ async def lifespan(app: FastAPI):
)
+class SlidingWindowTokenMiddleware(BaseHTTPMiddleware):
+ """Refresh the JWT token on each authenticated response.
+
+ When a request includes a valid Bearer token, the response includes a
+ X-Refreshed-Token header with a new token that has a fresh expiry.
+ This implements sliding-window session expiry: the session only expires
+ after a period of *inactivity*, not a fixed time after login.
+ """
+
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
+ response = await call_next(request)
+
+ # Only refresh on mutating requests (POST/PUT/PATCH/DELETE) — these indicate
+ # genuine user activity. GET requests are often background fetches (RTK Query
+ # cache revalidation, refetch-on-focus, etc.) and should not reset the
+ # inactivity timer.
+ if response.status_code < 400 and request.method in ("POST", "PUT", "PATCH", "DELETE"):
+ auth_header = request.headers.get("authorization", "")
+ if auth_header.startswith("Bearer "):
+ token = auth_header[7:]
+ try:
+ from invokeai.app.services.auth.token_service import create_access_token, verify_token
+
+ token_data = verify_token(token)
+ if token_data is not None:
+ # Determine expiry from the original token's remaining lifetime category.
+ # Tokens with > 1 day remaining were "remember me" tokens; refresh with 7 days.
+ # Others refresh with 1 day.
+ from datetime import timedelta
+
+ from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL, TOKEN_EXPIRATION_REMEMBER_ME
+ from jose import jwt
+ from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret
+ from datetime import datetime, timezone
+
+ payload = jwt.decode(token, get_jwt_secret(), algorithms=[ALGORITHM])
+ exp = payload.get("exp", 0)
+ remaining = exp - datetime.now(timezone.utc).timestamp()
+ # If more than 1 day remaining, this was a "remember me" token
+ if remaining > 86400:
+ expires_delta = timedelta(days=TOKEN_EXPIRATION_REMEMBER_ME)
+ else:
+ expires_delta = timedelta(days=TOKEN_EXPIRATION_NORMAL)
+
+ new_token = create_access_token(token_data, expires_delta)
+ response.headers["X-Refreshed-Token"] = new_token
+ except Exception:
+ pass # Don't fail the request if token refresh fails
+
+ return response
+
+
class RedirectRootWithQueryStringMiddleware(BaseHTTPMiddleware):
"""When a request is made to the root path with a query string, redirect to the root path without the query string.
@@ -99,6 +151,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
# Add the middleware
app.add_middleware(RedirectRootWithQueryStringMiddleware)
+app.add_middleware(SlidingWindowTokenMiddleware)
# Add event handler
@@ -117,6 +170,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
allow_credentials=app_config.allow_credentials,
allow_methods=app_config.allow_methods,
allow_headers=app_config.allow_headers,
+ expose_headers=["X-Refreshed-Token"],
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts
index 84be23bb12e..85a5d320a1a 100644
--- a/invokeai/frontend/web/src/services/api/index.ts
+++ b/invokeai/frontend/web/src/services/api/index.ts
@@ -113,6 +113,15 @@ const dynamicBaseQuery: BaseQueryFn
Date: Sat, 4 Apr 2026 14:01:25 -0400
Subject: [PATCH 06/13] chore(backend): ruff
---
invokeai/app/api_app.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py
index c94560850b5..f319994c084 100644
--- a/invokeai/app/api_app.py
+++ b/invokeai/app/api_app.py
@@ -107,12 +107,12 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
# Determine expiry from the original token's remaining lifetime category.
# Tokens with > 1 day remaining were "remember me" tokens; refresh with 7 days.
# Others refresh with 1 day.
- from datetime import timedelta
+ from datetime import datetime, timedelta, timezone
- from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL, TOKEN_EXPIRATION_REMEMBER_ME
from jose import jwt
+
+ from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL, TOKEN_EXPIRATION_REMEMBER_ME
from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret
- from datetime import datetime, timezone
payload = jwt.decode(token, get_jwt_secret(), algorithms=[ALGORITHM])
exp = payload.get("exp", 0)
From 16050766e3d6f03e4a9aed3c01963f5647808172 Mon Sep 17 00:00:00 2001
From: Lincoln Stein
Date: Sat, 4 Apr 2026 15:57:40 -0400
Subject: [PATCH 07/13] fix: address review feedback on auth token handling
Bug fixes:
- ProtectedRoute: only treat 401 errors as session expiry, not
transient 500/network errors that should not force logout
- Token refresh: use explicit remember_me claim in JWT instead of
inferring from remaining lifetime, preventing silent downgrade of
7-day tokens to 1-day when <24h remains
- TokenData: add remember_me field, set during login
Tests (6 new):
- Mutating requests (POST/PUT/DELETE) return X-Refreshed-Token
- GET requests do not return X-Refreshed-Token
- Unauthenticated requests do not return X-Refreshed-Token
- Remember-me token refreshes to 7-day duration even near expiry
- Normal token refreshes to 1-day duration
- remember_me claim preserved through refresh cycle
Co-Authored-By: Claude Opus 4.6 (1M context)
---
invokeai/app/api/routers/auth.py | 1 +
invokeai/app/api_app.py | 22 +--
invokeai/app/services/auth/token_service.py | 1 +
.../auth/components/ProtectedRoute.tsx | 6 +-
tests/app/api/__init__.py | 0
tests/app/api/test_sliding_window_token.py | 170 ++++++++++++++++++
6 files changed, 183 insertions(+), 17 deletions(-)
create mode 100644 tests/app/api/__init__.py
create mode 100644 tests/app/api/test_sliding_window_token.py
diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py
index b4c1e86cf33..36aeabda822 100644
--- a/invokeai/app/api/routers/auth.py
+++ b/invokeai/app/api/routers/auth.py
@@ -150,6 +150,7 @@ async def login(
user_id=user.user_id,
email=user.email,
is_admin=user.is_admin,
+ remember_me=request.remember_me,
)
token = create_access_token(token_data, expires_delta)
diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py
index f319994c084..2ca6746b496 100644
--- a/invokeai/app/api_app.py
+++ b/invokeai/app/api_app.py
@@ -100,25 +100,17 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
if auth_header.startswith("Bearer "):
token = auth_header[7:]
try:
+ from datetime import timedelta
+
+ from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL, TOKEN_EXPIRATION_REMEMBER_ME
from invokeai.app.services.auth.token_service import create_access_token, verify_token
token_data = verify_token(token)
if token_data is not None:
- # Determine expiry from the original token's remaining lifetime category.
- # Tokens with > 1 day remaining were "remember me" tokens; refresh with 7 days.
- # Others refresh with 1 day.
- from datetime import datetime, timedelta, timezone
-
- from jose import jwt
-
- from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL, TOKEN_EXPIRATION_REMEMBER_ME
- from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret
-
- payload = jwt.decode(token, get_jwt_secret(), algorithms=[ALGORITHM])
- exp = payload.get("exp", 0)
- remaining = exp - datetime.now(timezone.utc).timestamp()
- # If more than 1 day remaining, this was a "remember me" token
- if remaining > 86400:
+ # Use the remember_me claim from the token to determine the
+ # correct refresh duration. This avoids the bug where a 7-day
+ # token with <24h remaining would be silently downgraded to 1 day.
+ if token_data.remember_me:
expires_delta = timedelta(days=TOKEN_EXPIRATION_REMEMBER_ME)
else:
expires_delta = timedelta(days=TOKEN_EXPIRATION_NORMAL)
diff --git a/invokeai/app/services/auth/token_service.py b/invokeai/app/services/auth/token_service.py
index 9c35261c380..2d766bb90aa 100644
--- a/invokeai/app/services/auth/token_service.py
+++ b/invokeai/app/services/auth/token_service.py
@@ -21,6 +21,7 @@ class TokenData(BaseModel):
user_id: str
email: str
is_admin: bool
+ remember_me: bool = False
def set_jwt_secret(secret: str) -> None:
diff --git a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx
index 60cb05761e4..82752523050 100644
--- a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx
+++ b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx
@@ -33,8 +33,10 @@ export const ProtectedRoute = memo(({ children, requireAdmin = false }: PropsWit
});
useEffect(() => {
- // If we have a token but fetching user failed, token is invalid/expired - logout
- if (userError && isAuthenticated) {
+ // Only treat 401 as session expiry. Other errors (500, network, etc.) are
+ // transient and should not force logout — the 401 handler in dynamicBaseQuery
+ // already covers the actual expiry case.
+ if (userError && isAuthenticated && 'status' in userError && userError.status === 401) {
dispatch(sessionExpiredLogout());
navigate('/login', { replace: true });
}
diff --git a/tests/app/api/__init__.py b/tests/app/api/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/app/api/test_sliding_window_token.py b/tests/app/api/test_sliding_window_token.py
new file mode 100644
index 00000000000..41c036f29c7
--- /dev/null
+++ b/tests/app/api/test_sliding_window_token.py
@@ -0,0 +1,170 @@
+"""Tests for SlidingWindowTokenMiddleware and token refresh behavior."""
+
+from datetime import timedelta
+
+import pytest
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+from fastapi.testclient import TestClient
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+
+from invokeai.app.services.auth.token_service import TokenData, create_access_token, set_jwt_secret
+
+
+@pytest.fixture(autouse=True)
+def _setup_jwt_secret():
+ """Ensure JWT secret is set for all tests."""
+ set_jwt_secret("test-secret-key-for-sliding-window-tests")
+
+
+def _create_test_app() -> FastAPI:
+ """Create a minimal FastAPI app with the SlidingWindowTokenMiddleware."""
+ from invokeai.app.api_app import SlidingWindowTokenMiddleware
+
+ test_app = FastAPI()
+ test_app.add_middleware(SlidingWindowTokenMiddleware)
+
+ @test_app.get("/test")
+ async def get_endpoint():
+ return {"ok": True}
+
+ @test_app.post("/test")
+ async def post_endpoint():
+ return {"ok": True}
+
+ @test_app.put("/test")
+ async def put_endpoint():
+ return {"ok": True}
+
+ @test_app.delete("/test")
+ async def delete_endpoint():
+ return {"ok": True}
+
+ return test_app
+
+
+def _make_token(remember_me: bool = False, expires_delta: timedelta | None = None) -> str:
+ """Create a test token."""
+ token_data = TokenData(
+ user_id="test-user",
+ email="test@test.com",
+ is_admin=False,
+ remember_me=remember_me,
+ )
+ return create_access_token(token_data, expires_delta)
+
+
+class TestSlidingWindowTokenMiddleware:
+ """Tests for SlidingWindowTokenMiddleware."""
+
+ def test_mutating_request_returns_refreshed_token(self):
+ """Authenticated POST/PUT/PATCH/DELETE requests return X-Refreshed-Token."""
+ app = _create_test_app()
+ client = TestClient(app)
+ token = _make_token()
+
+ for method in ["post", "put", "delete"]:
+ response = getattr(client, method)("/test", headers={"Authorization": f"Bearer {token}"})
+ assert response.status_code == 200
+ assert "X-Refreshed-Token" in response.headers, f"{method.upper()} should return refreshed token"
+
+ def test_get_request_does_not_return_refreshed_token(self):
+ """Authenticated GET requests do NOT return X-Refreshed-Token."""
+ app = _create_test_app()
+ client = TestClient(app)
+ token = _make_token()
+
+ response = client.get("/test", headers={"Authorization": f"Bearer {token}"})
+ assert response.status_code == 200
+ assert "X-Refreshed-Token" not in response.headers
+
+ def test_unauthenticated_request_does_not_return_refreshed_token(self):
+ """Requests without a token do NOT return X-Refreshed-Token."""
+ app = _create_test_app()
+ client = TestClient(app)
+
+ response = client.post("/test")
+ assert response.status_code == 200
+ assert "X-Refreshed-Token" not in response.headers
+
+ def test_remember_me_token_refreshes_to_remember_me_duration(self):
+ """A remember_me=True token refreshes with the remember-me duration, not the normal duration."""
+ from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_REMEMBER_ME
+ from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret, verify_token
+
+ from jose import jwt
+
+ app = _create_test_app()
+ client = TestClient(app)
+
+ # Create a remember-me token with only 1 hour remaining (less than 24h)
+ token = _make_token(remember_me=True, expires_delta=timedelta(hours=1))
+
+ response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
+ assert "X-Refreshed-Token" in response.headers
+
+ # Decode the refreshed token and check its expiry
+ refreshed_token = response.headers["X-Refreshed-Token"]
+ payload = jwt.decode(refreshed_token, get_jwt_secret(), algorithms=[ALGORITHM])
+
+ # The refreshed token should have ~7 days of remaining life, not ~1 day
+ from datetime import datetime, timezone
+
+ remaining_seconds = payload["exp"] - datetime.now(timezone.utc).timestamp()
+ remaining_days = remaining_seconds / 86400
+
+ # Should be close to TOKEN_EXPIRATION_REMEMBER_ME (7 days), not TOKEN_EXPIRATION_NORMAL (1 day)
+ assert remaining_days > TOKEN_EXPIRATION_REMEMBER_ME - 0.1, (
+ f"Remember-me token was downgraded: {remaining_days:.1f} days remaining, "
+ f"expected ~{TOKEN_EXPIRATION_REMEMBER_ME}"
+ )
+
+ def test_normal_token_refreshes_to_normal_duration(self):
+ """A remember_me=False token refreshes with the normal duration."""
+ from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL
+ from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret
+
+ from jose import jwt
+
+ app = _create_test_app()
+ client = TestClient(app)
+
+ token = _make_token(remember_me=False)
+
+ response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
+ refreshed_token = response.headers["X-Refreshed-Token"]
+ payload = jwt.decode(refreshed_token, get_jwt_secret(), algorithms=[ALGORITHM])
+
+ from datetime import datetime, timezone
+
+ remaining_seconds = payload["exp"] - datetime.now(timezone.utc).timestamp()
+ remaining_days = remaining_seconds / 86400
+
+ # Should be close to TOKEN_EXPIRATION_NORMAL (1 day), not TOKEN_EXPIRATION_REMEMBER_ME (7 days)
+ assert remaining_days < TOKEN_EXPIRATION_NORMAL + 0.1, (
+ f"Normal token got remember-me duration: {remaining_days:.1f} days"
+ )
+ assert remaining_days > TOKEN_EXPIRATION_NORMAL - 0.1, (
+ f"Normal token duration too short: {remaining_days:.1f} days"
+ )
+
+ def test_remember_me_claim_preserved_in_refreshed_token(self):
+ """The remember_me claim is preserved when a token is refreshed."""
+ from invokeai.app.services.auth.token_service import verify_token
+
+ app = _create_test_app()
+ client = TestClient(app)
+
+ # Test with remember_me=True
+ token = _make_token(remember_me=True)
+ response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
+ refreshed_data = verify_token(response.headers["X-Refreshed-Token"])
+ assert refreshed_data is not None
+ assert refreshed_data.remember_me is True
+
+ # Test with remember_me=False
+ token = _make_token(remember_me=False)
+ response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
+ refreshed_data = verify_token(response.headers["X-Refreshed-Token"])
+ assert refreshed_data is not None
+ assert refreshed_data.remember_me is False
From 119aa930309d373e700090a0edb63c3f1abb8ea0 Mon Sep 17 00:00:00 2001
From: Lincoln Stein
Date: Sat, 4 Apr 2026 15:59:58 -0400
Subject: [PATCH 08/13] chore(backend): ruff
---
tests/app/api/test_sliding_window_token.py | 14 ++++++--------
1 file changed, 6 insertions(+), 8 deletions(-)
diff --git a/tests/app/api/test_sliding_window_token.py b/tests/app/api/test_sliding_window_token.py
index 41c036f29c7..9a5e14f63e2 100644
--- a/tests/app/api/test_sliding_window_token.py
+++ b/tests/app/api/test_sliding_window_token.py
@@ -3,10 +3,8 @@
from datetime import timedelta
import pytest
-from fastapi import FastAPI, Request
-from fastapi.responses import JSONResponse
+from fastapi import FastAPI
from fastapi.testclient import TestClient
-from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from invokeai.app.services.auth.token_service import TokenData, create_access_token, set_jwt_secret
@@ -89,11 +87,11 @@ def test_unauthenticated_request_does_not_return_refreshed_token(self):
def test_remember_me_token_refreshes_to_remember_me_duration(self):
"""A remember_me=True token refreshes with the remember-me duration, not the normal duration."""
- from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_REMEMBER_ME
- from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret, verify_token
-
from jose import jwt
+ from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_REMEMBER_ME
+ from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret
+
app = _create_test_app()
client = TestClient(app)
@@ -121,11 +119,11 @@ def test_remember_me_token_refreshes_to_remember_me_duration(self):
def test_normal_token_refreshes_to_normal_duration(self):
"""A remember_me=False token refreshes with the normal duration."""
+ from jose import jwt
+
from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL
from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret
- from jose import jwt
-
app = _create_test_app()
client = TestClient(app)
From 5596fa0cc8373859921526a404e10aedc964397c Mon Sep 17 00:00:00 2001
From: Jonathan <34005131+JPPhoto@users.noreply.github.com>
Date: Sun, 5 Apr 2026 14:28:15 -0400
Subject: [PATCH 09/13] Upgrade spandrel version (#8996)
* Upgrade spandrel to 0.4.2 in uv.lock
* Fixed typos
---
uv.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/uv.lock b/uv.lock
index a22015f28ff..226aecacc31 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3324,7 +3324,7 @@ wheels = [
[[package]]
name = "spandrel"
-version = "0.4.1"
+version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "einops" },
@@ -3340,9 +3340,9 @@ dependencies = [
{ name = "torchvision", version = "0.22.1+rocm6.3", source = { registry = "https://download.pytorch.org/whl/rocm6.3" }, marker = "(extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra != 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (extra != 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm')" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/45/e0/048cd03119a9f2b685a79601a52311d5910ff6fd710c01f4ed6769a2892f/spandrel-0.4.1.tar.gz", hash = "sha256:646d9816a942e59d56aab2dc904353952e57dee4b2cb3f59f7ea4dc0fb11a1f2", size = 233544, upload-time = "2025-01-19T15:31:24.02Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2a/8f/ab4565c23dd67a036ab72101a830cebd7ca026b2fddf5771bbf6284f6228/spandrel-0.4.2.tar.gz", hash = "sha256:fefa4ea966c6a5b7721dcf24f3e2062a5a96a395c8bedcb570fb55971fdcbccb", size = 247544, upload-time = "2026-02-21T01:52:26.342Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d3/1e/5dce7f0d3eb2aa418bd9cf3e84b2f5d2cf45b1c62488dd139fc93c729cfe/spandrel-0.4.1-py3-none-any.whl", hash = "sha256:49a39aa979769749a42203428355bc4840452854d6334ce0d465af46098dd448", size = 305217, upload-time = "2025-01-19T15:31:22.202Z" },
+ { url = "https://files.pythonhosted.org/packages/74/31/411ea965835534c43d4b98d451968354876e0e867ea1fd42669e4cca0732/spandrel-0.4.2-py3-none-any.whl", hash = "sha256:6c93e3ecbeb0e548fd2df45a605472b34c1614287c56b51bb33cdef7ae5235b5", size = 320811, upload-time = "2026-02-21T01:52:25.015Z" },
]
[[package]]
From 41a542552e3a72f8d43abba3e7b8ed80243cc262 Mon Sep 17 00:00:00 2001
From: Jonathan <34005131+JPPhoto@users.noreply.github.com>
Date: Sun, 5 Apr 2026 14:32:35 -0400
Subject: [PATCH 10/13] Fix workflows info copy focus (#9015)
* Fix workflow copy hotkeys in info view
* Fix Makefile help target copy
* Fix workflow info view copy handling
* Fix workflow edge delete hotkeys
---
Makefile | 8 +-
.../web/src/common/hooks/focus.test.ts | 13 ++++
.../features/nodes/components/flow/Flow.tsx | 27 ++++---
.../components/flow/workflowHotkeys.test.ts | 76 +++++++++++++++++++
.../nodes/components/flow/workflowHotkeys.ts | 34 +++++++++
5 files changed, 143 insertions(+), 15 deletions(-)
create mode 100644 invokeai/frontend/web/src/common/hooks/focus.test.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/workflowHotkeys.test.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/workflowHotkeys.ts
diff --git a/Makefile b/Makefile
index f1e81429e73..2e452c5cc0f 100644
--- a/Makefile
+++ b/Makefile
@@ -12,12 +12,12 @@ help:
@echo "mypy-all Run mypy ignoring the config in pyproject.tom but still ignoring missing imports"
@echo "test Run the unit tests."
@echo "update-config-docstring Update the app's config docstring so mkdocs can autogenerate it correctly."
- @echo "frontend-install Install the pnpm modules needed for the front end"
- @echo "frontend-build Build the frontend in order to run on localhost:9090"
+ @echo "frontend-install Install the pnpm modules needed for the frontend"
+ @echo "frontend-build Build the frontend for localhost:9090"
@echo "frontend-dev Run the frontend in developer mode on localhost:5173"
@echo "frontend-typegen Generate types for the frontend from the OpenAPI schema"
- @echo "frontend-prettier Format the frontend using lint:prettier"
- @echo "wheel Build the wheel for the current version"
+ @echo "frontend-lint Run frontend checks and fixable lint/format steps"
+ @echo "wheel Build the wheel for the current version"
@echo "tag-release Tag the GitHub repository with the current version (use at release time only!)"
@echo "openapi Generate the OpenAPI schema for the app, outputting to stdout"
@echo "docs Serve the mkdocs site with live reload"
diff --git a/invokeai/frontend/web/src/common/hooks/focus.test.ts b/invokeai/frontend/web/src/common/hooks/focus.test.ts
new file mode 100644
index 00000000000..c106fe1cec4
--- /dev/null
+++ b/invokeai/frontend/web/src/common/hooks/focus.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest';
+
+import { getFocusedRegion, setFocusedRegion } from './focus';
+
+describe('focus regions', () => {
+ it('supports the workflows region', () => {
+ setFocusedRegion('workflows');
+ expect(getFocusedRegion()).toBe('workflows');
+
+ setFocusedRegion(null);
+ expect(getFocusedRegion()).toBe(null);
+ });
+});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx
index f6474dec74b..0c48eddfc6a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx
@@ -51,7 +51,7 @@ import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/stor
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
-import type { CSSProperties, MouseEvent } from 'react';
+import type { CSSProperties, MouseEvent, RefObject } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -61,6 +61,7 @@ import InvocationDefaultEdge from './edges/InvocationDefaultEdge';
import CurrentImageNode from './nodes/CurrentImage/CurrentImageNode';
import InvocationNodeWrapper from './nodes/Invocation/InvocationNodeWrapper';
import NotesNode from './nodes/Notes/NotesNode';
+import { isWorkflowHotkeyEnabled, shouldIgnoreWorkflowCopyHotkey } from './workflowHotkeys';
const edgeTypes = {
collapsed: InvocationCollapsedEdge,
@@ -248,14 +249,14 @@ export const Flow = memo(() => {
>
-
+
>
);
});
Flow.displayName = 'Flow';
-const HotkeyIsolator = memo(() => {
+const HotkeyIsolator = memo(({ flowWrapper }: { flowWrapper: RefObject }) => {
const mayUndo = useAppSelector(selectMayUndo);
const mayRedo = useAppSelector(selectMayRedo);
@@ -270,8 +271,12 @@ const HotkeyIsolator = memo(() => {
id: 'copySelection',
category: 'workflows',
callback: copySelection,
- options: { enabled: isWorkflowsFocused, preventDefault: true },
- dependencies: [copySelection],
+ options: {
+ enabled: isWorkflowHotkeyEnabled(isWorkflowsFocused),
+ preventDefault: true,
+ ignoreEventWhen: () => shouldIgnoreWorkflowCopyHotkey(window.getSelection(), flowWrapper.current),
+ },
+ dependencies: [copySelection, isWorkflowsFocused],
});
const selectAll = useCallback(() => {
@@ -299,7 +304,7 @@ const HotkeyIsolator = memo(() => {
id: 'selectAll',
category: 'workflows',
callback: selectAll,
- options: { enabled: isWorkflowsFocused, preventDefault: true },
+ options: { enabled: isWorkflowHotkeyEnabled(isWorkflowsFocused), preventDefault: true },
dependencies: [selectAll, isWorkflowsFocused],
});
@@ -307,7 +312,7 @@ const HotkeyIsolator = memo(() => {
id: 'pasteSelection',
category: 'workflows',
callback: pasteSelection,
- options: { enabled: isWorkflowsFocused, preventDefault: true },
+ options: { enabled: isWorkflowHotkeyEnabled(isWorkflowsFocused), preventDefault: true },
dependencies: [pasteSelection, isWorkflowsFocused],
});
@@ -315,7 +320,7 @@ const HotkeyIsolator = memo(() => {
id: 'pasteSelectionWithEdges',
category: 'workflows',
callback: pasteSelectionWithEdges,
- options: { enabled: isWorkflowsFocused, preventDefault: true },
+ options: { enabled: isWorkflowHotkeyEnabled(isWorkflowsFocused), preventDefault: true },
dependencies: [pasteSelectionWithEdges, isWorkflowsFocused],
});
@@ -325,7 +330,7 @@ const HotkeyIsolator = memo(() => {
callback: () => {
store.dispatch(undo());
},
- options: { enabled: isWorkflowsFocused && mayUndo, preventDefault: true },
+ options: { enabled: isWorkflowHotkeyEnabled(isWorkflowsFocused) && mayUndo, preventDefault: true },
dependencies: [store, mayUndo, isWorkflowsFocused],
});
@@ -335,7 +340,7 @@ const HotkeyIsolator = memo(() => {
callback: () => {
store.dispatch(redo());
},
- options: { enabled: isWorkflowsFocused && mayRedo, preventDefault: true },
+ options: { enabled: isWorkflowHotkeyEnabled(isWorkflowsFocused) && mayRedo, preventDefault: true },
dependencies: [store, mayRedo, isWorkflowsFocused],
});
@@ -373,7 +378,7 @@ const HotkeyIsolator = memo(() => {
id: 'deleteSelection',
category: 'workflows',
callback: deleteSelection,
- options: { preventDefault: true, enabled: isWorkflowsFocused },
+ options: { preventDefault: true, enabled: isWorkflowHotkeyEnabled(isWorkflowsFocused) },
dependencies: [deleteSelection, isWorkflowsFocused],
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/workflowHotkeys.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/workflowHotkeys.test.ts
new file mode 100644
index 00000000000..e901683d2d4
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/workflowHotkeys.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest';
+
+import { isEventTargetWithinElement, isWorkflowHotkeyEnabled, shouldIgnoreWorkflowCopyHotkey } from './workflowHotkeys';
+
+describe('isEventTargetWithinElement', () => {
+ it('returns true when the element contains the event target', () => {
+ const target = new EventTarget();
+ const element = {
+ contains: (node: unknown) => node === target,
+ };
+
+ expect(isEventTargetWithinElement(target, element as never)).toBe(true);
+ });
+
+ it('returns false when the element does not contain the event target', () => {
+ const target = new EventTarget();
+ const element = {
+ contains: () => false,
+ };
+
+ expect(isEventTargetWithinElement(target, element as never)).toBe(false);
+ });
+
+ it('returns false when the element is missing', () => {
+ expect(isEventTargetWithinElement(new EventTarget(), null)).toBe(false);
+ });
+});
+
+describe('isWorkflowHotkeyEnabled', () => {
+ it('enables workflow hotkeys whenever the workflows pane is focused', () => {
+ expect(isWorkflowHotkeyEnabled(true)).toBe(true);
+ });
+
+ it('disables workflow hotkeys when the workflows pane is not focused', () => {
+ expect(isWorkflowHotkeyEnabled(false)).toBe(false);
+ });
+});
+
+describe('shouldIgnoreWorkflowCopyHotkey', () => {
+ const insideNode = new EventTarget() as Node;
+ const outsideNode = new EventTarget() as Node;
+ const element = {
+ contains: (node: Node) => node === insideNode,
+ };
+
+ it('returns false when there is no selection', () => {
+ expect(shouldIgnoreWorkflowCopyHotkey(null, element)).toBe(false);
+ });
+
+ it('returns false for collapsed selections', () => {
+ expect(
+ shouldIgnoreWorkflowCopyHotkey(
+ { isCollapsed: true, toString: () => 'text', anchorNode: outsideNode, focusNode: outsideNode },
+ element
+ )
+ ).toBe(false);
+ });
+
+ it('returns false when the selection is inside the editor element', () => {
+ expect(
+ shouldIgnoreWorkflowCopyHotkey(
+ { isCollapsed: false, toString: () => 'text', anchorNode: insideNode, focusNode: insideNode },
+ element
+ )
+ ).toBe(false);
+ });
+
+ it('returns true when the selection is outside the editor element', () => {
+ expect(
+ shouldIgnoreWorkflowCopyHotkey(
+ { isCollapsed: false, toString: () => 'text', anchorNode: outsideNode, focusNode: outsideNode },
+ element
+ )
+ ).toBe(true);
+ });
+});
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/workflowHotkeys.ts b/invokeai/frontend/web/src/features/nodes/components/flow/workflowHotkeys.ts
new file mode 100644
index 00000000000..4de23face82
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/workflowHotkeys.ts
@@ -0,0 +1,34 @@
+export const isEventTargetWithinElement = (
+ target: EventTarget | null,
+ element: { contains: (node: Node) => boolean } | null
+) => {
+ return Boolean(target && element?.contains(target as Node));
+};
+
+export const isWorkflowHotkeyEnabled = (isWorkflowsFocused: boolean) => {
+ return isWorkflowsFocused;
+};
+
+type SelectionLike = {
+ isCollapsed: boolean;
+ toString(): string;
+ anchorNode: Node | null;
+ focusNode: Node | null;
+};
+
+export const shouldIgnoreWorkflowCopyHotkey = (
+ selection: SelectionLike | null | undefined,
+ element: { contains: (node: Node) => boolean } | null
+) => {
+ if (!selection || !element || selection.isCollapsed || selection.toString().length === 0) {
+ return false;
+ }
+
+ const nodes = [selection.anchorNode, selection.focusNode].filter((node): node is Node => node !== null);
+
+ if (nodes.length === 0) {
+ return false;
+ }
+
+ return nodes.some((node) => !element.contains(node));
+};
From 471ab9d9c0eb1b4eca10f58124aaf0755e66af65 Mon Sep 17 00:00:00 2001
From: Alexander Eichhorn
Date: Sun, 5 Apr 2026 23:59:44 +0200
Subject: [PATCH 11/13] feat: add Inpaint Mask as drag & drop target on canvas
(#8942)
Closes #8843
Co-authored-by: dunkeroni
---
.../components/CanvasDropArea.tsx | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
index ebb8e414048..6955b621caf 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
@@ -12,6 +12,7 @@ const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.
const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
type: 'regional_guidance_with_reference_image',
});
+const addInpaintMaskFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ type: 'inpaint_mask' });
const addResizedControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
type: 'control_layer',
withResize: true,
@@ -25,7 +26,7 @@ export const CanvasDropArea = memo(() => {
<>
{
left={0}
pointerEvents="none"
>
-
+
{
isDisabled={isBusy}
/>
-
+
{
isDisabled={isBusy}
/>
-
+
{
isDisabled={isBusy}
/>
-
+
+
+
+
Date: Mon, 6 Apr 2026 01:33:47 +0300
Subject: [PATCH 12/13] Fix to retain layer opacity on mode switch. (#8879)
Co-authored-by: dunkeroni
---
.../konva/CanvasEntity/CanvasEntityAdapterBase.ts | 1 +
.../konva/CanvasEntity/CanvasEntityObjectRenderer.ts | 4 ----
2 files changed, 1 insertion(+), 4 deletions(-)
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts
index c733334ed09..6751e58da28 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts
@@ -542,6 +542,7 @@ export abstract class CanvasEntityAdapterBase {
- if (!this.parent.konva.layer.visible()) {
- return;
- }
-
this.log.trace('Updating opacity');
const opacity = this.parent.state.opacity;
From be015a5434852797fc9a00a3f55591eedd8bc48b Mon Sep 17 00:00:00 2001
From: Jonathan <34005131+JPPhoto@users.noreply.github.com>
Date: Sun, 5 Apr 2026 19:18:24 -0400
Subject: [PATCH 13/13] Run vitest during frontend build (#9022)
* Run vitest during frontend build
* Add frontend-test Make target
---
Makefile | 5 +++++
invokeai/frontend/web/package.json | 3 ++-
invokeai/frontend/web/vite.config.mts | 1 +
3 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index 2e452c5cc0f..ecf101f1d55 100644
--- a/Makefile
+++ b/Makefile
@@ -14,6 +14,7 @@ help:
@echo "update-config-docstring Update the app's config docstring so mkdocs can autogenerate it correctly."
@echo "frontend-install Install the pnpm modules needed for the frontend"
@echo "frontend-build Build the frontend for localhost:9090"
+ @echo "frontend-test Run the frontend test suite once"
@echo "frontend-dev Run the frontend in developer mode on localhost:5173"
@echo "frontend-typegen Generate types for the frontend from the OpenAPI schema"
@echo "frontend-lint Run frontend checks and fixable lint/format steps"
@@ -57,6 +58,10 @@ frontend-install:
frontend-build:
cd invokeai/frontend/web && pnpm build
+# Run the frontend test suite once
+frontend-test:
+ cd invokeai/frontend/web && pnpm run test:run
+
# Run the frontend in dev mode
frontend-dev:
cd invokeai/frontend/web && pnpm dev
diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index da4e31142f2..e9a896f1b4e 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -21,7 +21,7 @@
"scripts": {
"dev": "vite dev",
"dev:host": "vite dev --host",
- "build": "pnpm run lint && vite build",
+ "build": "pnpm run lint && vitest run && vite build",
"typegen": "node scripts/typegen.js",
"preview": "vite preview",
"lint:knip": "knip --tags=-knipignore",
@@ -35,6 +35,7 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "vitest",
+ "test:run": "vitest run",
"test:ui": "vitest --coverage --ui",
"test:no-watch": "vitest --no-watch"
},
diff --git a/invokeai/frontend/web/vite.config.mts b/invokeai/frontend/web/vite.config.mts
index d15c35d6bce..b3afe5fdeb0 100644
--- a/invokeai/frontend/web/vite.config.mts
+++ b/invokeai/frontend/web/vite.config.mts
@@ -39,6 +39,7 @@ export default defineConfig(({ mode }) => {
host: '0.0.0.0',
},
test: {
+ reporters: [['default', { summary: false }]],
typecheck: {
enabled: true,
ignoreSourceErrors: true,