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,