From bc5ba09faf09656c8921c254a5420dc8d69debd9 Mon Sep 17 00:00:00 2001 From: Suren Date: Fri, 20 Feb 2026 08:53:25 +0530 Subject: [PATCH 01/11] fix: prevent open redirect in login and OAuth2 redirect flows (#41550) --- .../ce/AuthenticationSuccessHandlerCE.java | 6 +- ...thenticationFailureRetryHandlerCEImpl.java | 7 +- .../server/helpers/RedirectHelper.java | 129 +++++- .../server/services/ce/UserServiceCEImpl.java | 8 +- .../RedirectHelperOpenRedirectTest.java | 381 ++++++++++++++++++ 5 files changed, 527 insertions(+), 4 deletions(-) create mode 100644 app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/RedirectHelperOpenRedirectTest.java diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationSuccessHandlerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationSuccessHandlerCE.java index cd4322eff17d..97a6292e717c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationSuccessHandlerCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationSuccessHandlerCE.java @@ -379,11 +379,15 @@ private Mono handleOAuth2Redirect( } } + // Sanitize the redirect URL extracted from the state parameter to prevent open redirect attacks. + // An attacker could craft a malicious state parameter containing an external URL. + HttpHeaders headers = exchange.getRequest().getHeaders(); + redirectUrl = RedirectHelper.sanitizeRedirectUrl(redirectUrl, headers); + boolean addFirstTimeExperienceParam = false; if (isFromSignup) { if (redirectHelper.isDefaultRedirectUrl(redirectUrl) && defaultApplication != null) { addFirstTimeExperienceParam = true; - HttpHeaders headers = exchange.getRequest().getHeaders(); redirectUrl = redirectHelper.buildApplicationUrl(defaultApplication, headers); } redirectUrl = redirectHelper.buildSignupSuccessUrl(redirectUrl, addFirstTimeExperienceParam); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/helpers/AuthenticationFailureRetryHandlerCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/helpers/AuthenticationFailureRetryHandlerCEImpl.java index 4ba2efd2c473..860657edab9b 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/helpers/AuthenticationFailureRetryHandlerCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/helpers/AuthenticationFailureRetryHandlerCEImpl.java @@ -2,6 +2,7 @@ import com.appsmith.server.constants.Security; import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.helpers.RedirectHelper; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.core.AuthenticationException; @@ -49,6 +50,10 @@ public Mono retryAndRedirectOnAuthenticationFailure( getOriginFromReferer(exchange.getRequest().getHeaders().getOrigin()); } + // Sanitize originHeader to prevent open redirect via crafted state parameter + originHeader = RedirectHelper.sanitizeRedirectUrl( + originHeader, exchange.getRequest().getHeaders()); + // Construct the redirect URL based on the exception type String url = constructRedirectUrl(exception, originHeader, redirectUrl); @@ -102,7 +107,7 @@ private String constructRedirectUrl(AuthenticationException exception, String or } } if (redirectUrl != null && !redirectUrl.trim().isEmpty()) { - url = url + "&" + REDIRECT_URL_QUERY_PARAM + "=" + redirectUrl; + url = url + "&" + REDIRECT_URL_QUERY_PARAM + "=" + URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8); } return url; } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java index 081321e6f10a..f79ef6640a2f 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java @@ -7,6 +7,7 @@ import com.appsmith.server.repositories.ApplicationRepository; import com.appsmith.server.solutions.ApplicationPermission; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.web.server.DefaultServerRedirectStrategy; @@ -23,6 +24,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +@Slf4j @Component @RequiredArgsConstructor public class RedirectHelper { @@ -60,7 +62,11 @@ public Mono getRedirectUrl(ServerHttpRequest request) { } else if (queryParams.getFirst(FORK_APP_ID_QUERY_PARAM) != null) { final String forkAppId = queryParams.getFirst(FORK_APP_ID_QUERY_PARAM); - final String defaultRedirectUrl = httpHeaders.getOrigin() + DEFAULT_REDIRECT_URL; + final String origin = httpHeaders.getOrigin(); + if (origin == null) { + return Mono.just(DEFAULT_REDIRECT_URL); + } + final String defaultRedirectUrl = origin + DEFAULT_REDIRECT_URL; return applicationRepository .findByClonedFromApplicationId(forkAppId, applicationPermission.getReadPermission()) .map(application -> { @@ -123,6 +129,8 @@ private static String getRedirectUrlFromHeader(HttpHeaders httpHeaders) { /** * If redirectUrl is empty, it'll be set to DEFAULT_REDIRECT_URL. * If the redirectUrl does not have the base url, it'll prepend that from header origin. + * If the redirectUrl is an absolute URL pointing to a different host, it is rejected + * to prevent open redirect attacks. * * @param redirectUrl * @param httpHeaders @@ -138,9 +146,128 @@ private static String fulfillRedirectUrl(String redirectUrl, HttpHeaders httpHea redirectUrl = httpHeaders.getOrigin() + redirectUrl; } + // Validate that absolute redirect URLs point to the same origin as the request. + // This prevents open redirect attacks where an attacker supplies an external URL + // (e.g., https://evil.com) as the redirectUrl parameter. + redirectUrl = sanitizeRedirectUrl(redirectUrl, httpHeaders); + return redirectUrl; } + /** + * Checks whether a redirect URL is safe by verifying it is either: + * - A relative path (no scheme), or + * - An absolute URL whose host matches the request's Origin header + * + * This prevents open redirect vulnerabilities where user-supplied URLs + * could redirect authenticated users to attacker-controlled domains. + * + * @param redirectUrl The URL to validate + * @param httpHeaders The HTTP headers from the current request + * @return true if the URL is safe to redirect to, false otherwise + */ + static boolean isSafeRedirectUrl(String redirectUrl, HttpHeaders httpHeaders) { + if (!StringUtils.hasText(redirectUrl)) { + return true; + } + + // Only single-slash-prefixed relative paths are safe (e.g., /applications) + if (redirectUrl.startsWith("/") && !redirectUrl.startsWith("//")) { + return true; + } + + // Reject anything that isn't http(s) — covers javascript:, data:, //, bare paths, etc. + if (!redirectUrl.startsWith("http://") && !redirectUrl.startsWith("https://")) { + return false; + } + + // For absolute URLs, the host must match the request origin + String origin = httpHeaders.getOrigin(); + if (StringUtils.isEmpty(origin)) { + // If there is no Origin header, we cannot validate — reject absolute URLs + // to be safe. Relative URLs were already allowed above. + return false; + } + + try { + URI redirectUri = new URI(redirectUrl); + URI originUri = new URI(origin); + + // Reject URLs with userinfo (e.g., https://evil.com@app.appsmith.com) + // Java's URI parser treats evil.com as userinfo and app.appsmith.com as host, + // but browser behavior varies — block these outright to be safe. + if (redirectUri.getUserInfo() != null) { + return false; + } + + String redirectHost = redirectUri.getHost(); + String originHost = originUri.getHost(); + + if (redirectHost == null || originHost == null) { + return false; + } + + // Compare host and port. + // When both URIs omit the port (raw port == -1), treat them as matching + // regardless of scheme — a scheme downgrade (https → http) on the same host + // is a transport-security concern, not an open redirect. + // When at least one port is explicit, normalize default ports per scheme + // (80 for http, 443 for https) before comparing. + int rawRedirectPort = redirectUri.getPort(); + int rawOriginPort = originUri.getPort(); + boolean portsMatch; + if (rawRedirectPort == -1 && rawOriginPort == -1) { + portsMatch = true; + } else { + portsMatch = normalizePort(redirectUri.getScheme(), rawRedirectPort) + == normalizePort(originUri.getScheme(), rawOriginPort); + } + + return redirectHost.equalsIgnoreCase(originHost) && portsMatch; + } catch (URISyntaxException e) { + return false; + } + } + + /** + * Normalizes a port number, mapping -1 (unspecified) to the default port for the scheme. + * This ensures that https://app.com and https://app.com:443 are treated as equivalent. + */ + private static int normalizePort(String scheme, int port) { + if (port != -1) { + return port; + } + if ("https".equalsIgnoreCase(scheme)) { + return 443; + } + if ("http".equalsIgnoreCase(scheme)) { + return 80; + } + return port; + } + + /** + * Sanitizes a redirect URL to prevent open redirect attacks. + * If the URL is not safe (points to an external host), returns the default redirect URL. + * This method is intended for use by authentication handlers that construct redirect URLs + * from sources other than fulfillRedirectUrl (e.g., OAuth2 state parameter). + * + * @param redirectUrl The URL to sanitize + * @param httpHeaders The HTTP headers from the current request + * @return The original URL if safe, or the default redirect URL if not + */ + public static String sanitizeRedirectUrl(String redirectUrl, HttpHeaders httpHeaders) { + if (isSafeRedirectUrl(redirectUrl, httpHeaders)) { + return redirectUrl; + } + String sanitizedLog = redirectUrl.replaceAll("[\\r\\n]", ""); + log.warn( + "Blocked open redirect attempt to: {}", + sanitizedLog.length() > 200 ? sanitizedLog.substring(0, 200) + "..." : sanitizedLog); + String origin = httpHeaders.getOrigin(); + return (!StringUtils.isEmpty(origin) ? origin : "") + DEFAULT_REDIRECT_URL; + } + /** * This function only checks the incoming request for all possible sources of a redirection domain * and returns with the first valid domain that it finds diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java index b7f6f6ff9ab1..d2b20b446f0e 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java @@ -22,6 +22,7 @@ import com.appsmith.server.dtos.UserUpdateDTO; import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; +import com.appsmith.server.helpers.RedirectHelper; import com.appsmith.server.helpers.UserServiceHelper; import com.appsmith.server.helpers.UserUtils; import com.appsmith.server.instanceconfigs.helpers.InstanceVariablesHelper; @@ -967,9 +968,14 @@ public Mono verifyEmailVerificationToken(ServerWebExchange exchange) { String baseUrl = exchange.getRequest().getHeaders().getOrigin(); if (redirectUrl == null) { redirectUrl = baseUrl + DEFAULT_REDIRECT_URL; + } else { + // Sanitize user-supplied redirectUrl to prevent open redirect attacks + redirectUrl = RedirectHelper.sanitizeRedirectUrl( + redirectUrl, exchange.getRequest().getHeaders()); } - String postVerificationRedirectUrl = "/signup-success?redirectUrl=" + redirectUrl + String postVerificationRedirectUrl = "/signup-success?redirectUrl=" + + URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8) + "&enableFirstTimeUserExperience=" + enableFirstTimeUserExperienceParam; String errorRedirectUrl = ""; diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/RedirectHelperOpenRedirectTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/RedirectHelperOpenRedirectTest.java new file mode 100644 index 000000000000..fb3206fa1696 --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/RedirectHelperOpenRedirectTest.java @@ -0,0 +1,381 @@ +package com.appsmith.server.helpers; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.http.HttpHeaders; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for open redirect prevention in RedirectHelper. + * + * The isSafeRedirectUrl() method ensures that absolute redirect URLs + * only point to the same origin as the request, preventing attackers + * from crafting login links that redirect authenticated users to + * malicious domains. + */ +class RedirectHelperOpenRedirectTest { + + private HttpHeaders headersWithOrigin(String origin) { + HttpHeaders headers = new HttpHeaders(); + headers.setOrigin(origin); + return headers; + } + + // --- isSafeRedirectUrl tests --- + + @Test + void testRelativeUrlIsAlwaysSafe() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertTrue(RedirectHelper.isSafeRedirectUrl("/applications", headers)); + assertTrue(RedirectHelper.isSafeRedirectUrl("/applications/123/pages/456/edit", headers)); + assertTrue(RedirectHelper.isSafeRedirectUrl("/signup-success?redirectUrl=%2Fapplications", headers)); + } + + @Test + void testNullAndEmptyUrlIsSafe() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertTrue(RedirectHelper.isSafeRedirectUrl(null, headers)); + assertTrue(RedirectHelper.isSafeRedirectUrl("", headers)); + assertTrue(RedirectHelper.isSafeRedirectUrl(" ", headers)); + } + + @Test + void testSameOriginAbsoluteUrlIsSafe() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertTrue(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/applications", headers)); + assertTrue( + RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/applications/123/pages/456/edit", headers)); + } + + @Test + void testSameOriginWithPortIsSafe() { + HttpHeaders headers = headersWithOrigin("http://localhost:8080"); + assertTrue(RedirectHelper.isSafeRedirectUrl("http://localhost:8080/applications", headers)); + assertTrue(RedirectHelper.isSafeRedirectUrl("http://localhost:8080/applications/123", headers)); + } + + @Test + void testDifferentHostIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com/phish", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("http://attacker.org/steal-cookies", headers)); + } + + @Test + void testDifferentPortIsBlocked() { + HttpHeaders headers = headersWithOrigin("http://localhost:8080"); + assertFalse(RedirectHelper.isSafeRedirectUrl("http://localhost:9090/applications", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("http://localhost:3000/applications", headers)); + } + + @Test + void testSubdomainMismatchIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.appsmith.com/phish", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("https://appsmith.com/applications", headers)); + } + + @Test + void testAbsoluteUrlWithNoOriginHeaderIsBlocked() { + HttpHeaders headers = new HttpHeaders(); // no Origin header + assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com/phish", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/applications", headers)); + } + + @Test + void testRelativeUrlWithNoOriginHeaderIsSafe() { + HttpHeaders headers = new HttpHeaders(); // no Origin header + assertTrue(RedirectHelper.isSafeRedirectUrl("/applications", headers)); + assertTrue(RedirectHelper.isSafeRedirectUrl("/applications/123/pages/456/edit", headers)); + } + + @Test + void testMalformedUrlIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com:not-a-port/phish", headers)); + } + + @Test + void testCaseInsensitiveHostComparison() { + HttpHeaders headers = headersWithOrigin("https://App.Appsmith.COM"); + assertTrue(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/applications", headers)); + assertTrue(RedirectHelper.isSafeRedirectUrl("https://APP.APPSMITH.COM/applications", headers)); + } + + @Test + void testSchemeDowngradeIsAllowed() { + // If origin is https but redirect is http to same host, the host check passes. + // The scheme mismatch is not a security concern for open redirect prevention. + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertTrue(RedirectHelper.isSafeRedirectUrl("http://app.appsmith.com/applications", headers)); + } + + @Test + void testExplicitDefaultPortMatchesImplicitPort() { + // https://app.com:443 should match https://app.com (port 443 is default for https) + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertTrue(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com:443/applications", headers)); + + // http://localhost:80 should match http://localhost (port 80 is default for http) + HttpHeaders headers2 = headersWithOrigin("http://localhost"); + assertTrue(RedirectHelper.isSafeRedirectUrl("http://localhost:80/applications", headers2)); + } + + // --- sanitizeRedirectUrl tests --- + + @Test + void testSanitizePassesSafeUrl() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertEquals( + "https://app.appsmith.com/applications/123", + RedirectHelper.sanitizeRedirectUrl("https://app.appsmith.com/applications/123", headers)); + } + + @Test + void testSanitizeBlocksExternalUrl() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + String result = RedirectHelper.sanitizeRedirectUrl("https://evil.com/phish", headers); + assertEquals("https://app.appsmith.com/applications", result); + } + + @Test + void testSanitizePassesRelativeUrl() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertEquals("/applications/123", RedirectHelper.sanitizeRedirectUrl("/applications/123", headers)); + } + + @Test + void testSanitizeWithNoOriginFallsBackToDefault() { + HttpHeaders headers = new HttpHeaders(); + String result = RedirectHelper.sanitizeRedirectUrl("https://evil.com/phish", headers); + assertEquals("/applications", result); + } + + // --- Common bypass attempts --- + + @ParameterizedTest + @CsvSource({ + "https://evil.com@app.appsmith.com/", + "https://app.appsmith.com.evil.com/", + "https://evil.com/app.appsmith.com", + "https://evil.com#@app.appsmith.com", + "//evil.com/path", + "//evil.com", + }) + void testCommonBypassAttemptsAreBlocked(String maliciousUrl) { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse( + RedirectHelper.isSafeRedirectUrl(maliciousUrl, headers), + "Should block bypass attempt: " + maliciousUrl); + } + + // --- Dangerous scheme tests --- + // javascript:, data:, and other non-http schemes are blocked outright. + // Only /path relative URLs and http(s) absolute URLs are allowed. + + @Test + void testJavascriptSchemeIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl("javascript:alert(1)", headers)); + } + + @Test + void testDataSchemeIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl("data:text/html,", headers)); + } + + @Test + void testBarePathWithoutSlashIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + // Bare paths like "applications" (no leading /) are rejected + assertFalse(RedirectHelper.isSafeRedirectUrl("applications", headers)); + } + + @Test + void testSanitizeBlocksProtocolRelativeUrl() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + String result = RedirectHelper.sanitizeRedirectUrl("//evil.com/path", headers); + assertEquals("https://app.appsmith.com/applications", result); + } + + // --- Additional edge-case bypass attempts --- + + @ParameterizedTest + @CsvSource({ + "HTTP://evil.com/phish", + "HTTPS://evil.com/phish", + "HtTp://evil.com/phish", + }) + void testUppercaseSchemesAreBlocked(String url) { + // Our startsWith check is intentionally case-sensitive; non-lowercase schemes are rejected. + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl(url, headers), "Should block uppercase scheme: " + url); + } + + @ParameterizedTest + @CsvSource({ + "///evil.com/path", + "////evil.com/path", + }) + void testTripleAndQuadSlashAreBlocked(String url) { + // Multiple leading slashes should not bypass protocol-relative URL detection. + // ///evil.com starts with // so it is rejected, and //// likewise. + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl(url, headers), "Should block multi-slash: " + url); + } + + @Test + void testBackslashConfusionIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + // Backslash-based bypass attempts — not starting with http:// or https://, so rejected. + assertFalse(RedirectHelper.isSafeRedirectUrl("https:\\\\evil.com", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("http:\\\\evil.com", headers)); + } + + @Test + void testSchemeDowngradeWithExplicitPortIsBlocked() { + // http://host:80 vs https://host — one port is explicit, so normalize per scheme. + // normalizePort(http, 80)=80, normalizePort(https, -1)=443 → different → blocked. + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl("http://app.appsmith.com:80/applications", headers)); + } + + @Test + void testSchemeUpgradeIsAllowed() { + // http origin, https redirect — same host, both implicit ports → allowed. + HttpHeaders headers = headersWithOrigin("http://app.appsmith.com"); + assertTrue(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/applications", headers)); + } + + @Test + void testIPAddressHostMatchIsSafe() { + HttpHeaders headers = headersWithOrigin("http://127.0.0.1:8080"); + assertTrue(RedirectHelper.isSafeRedirectUrl("http://127.0.0.1:8080/applications", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("http://127.0.0.2:8080/applications", headers)); + } + + @Test + void testEmptyAuthorityIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + // https:///path has empty authority — host will be null, so blocked. + assertFalse(RedirectHelper.isSafeRedirectUrl("https:///path", headers)); + } + + // --- URL encoding bypass attempts --- + + @ParameterizedTest + @CsvSource({ + "%2F%2Fevil.com", + "%2f%2fevil.com", + "%2F%2Fevil.com/path", + }) + void testUrlEncodedDoubleSlashIsBlocked(String url) { + // URL-encoded // should not bypass the protocol-relative check. + // Java's URI parser does NOT decode %2F, so these become opaque paths + // that don't start with / — rejected by the "bare path" check. + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl(url, headers), "Should block encoded //: " + url); + } + + @Test + void testDoubleEncodedSlashIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + // %252F%252F decodes to %2F%2F on first pass — not a valid scheme or relative path + assertFalse(RedirectHelper.isSafeRedirectUrl("%252F%252Fevil.com", headers)); + } + + // --- Control character injection --- + + @ParameterizedTest + @CsvSource({ + "https://evil.com%09/path", + "https://evil.com%0a/path", + "https://evil.com%0d/path", + "https://evil.com%0d%0a/path", + "https://evil.com%00/path", + }) + void testControlCharacterInjectionIsBlocked(String url) { + // Control characters (tab, LF, CR, CRLF, null) in URLs should not bypass validation. + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl(url, headers), "Should block control char injection: " + url); + } + + // --- Whitespace attacks --- + + @Test + void testWhitespacePrefixIsHandled() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + // Leading whitespace before a malicious URL — StringUtils.hasText passes, + // but it won't start with / or http(s):// → rejected. + assertFalse(RedirectHelper.isSafeRedirectUrl(" https://evil.com", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("\thttps://evil.com", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("\nhttps://evil.com", headers)); + } + + // --- IPv6 address handling --- + + @Test + void testIPv6LocalhostMatch() { + HttpHeaders headers = headersWithOrigin("http://[::1]:8080"); + assertTrue(RedirectHelper.isSafeRedirectUrl("http://[::1]:8080/applications", headers)); + // Different IPv6 address should be blocked + assertFalse(RedirectHelper.isSafeRedirectUrl("http://[::2]:8080/applications", headers)); + } + + @Test + void testIPv6VsIPv4Mismatch() { + // IPv6 localhost [::1] should NOT match IPv4 127.0.0.1 + HttpHeaders headers = headersWithOrigin("http://127.0.0.1:8080"); + assertFalse(RedirectHelper.isSafeRedirectUrl("http://[::1]:8080/applications", headers)); + } + + // --- Single-slash scheme malformation --- + + @Test + void testSingleSlashSchemeIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + // https:/evil.com — missing a slash, not a valid http(s):// URL + assertFalse(RedirectHelper.isSafeRedirectUrl("https:/evil.com", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("http:/evil.com", headers)); + } + + // --- Path traversal in URL --- + + @Test + void testPathTraversalInAbsoluteUrl() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + // Same host with path traversal — host still matches, so this is safe + assertTrue(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/../etc/passwd", headers)); + // Different host with path traversal — blocked + assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com/../app.appsmith.com", headers)); + } + + // --- Fragment-based confusion --- + + @Test + void testFragmentWithDotDomainIsBlocked() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com#.app.appsmith.com", headers)); + assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com#app.appsmith.com", headers)); + } + + // --- sanitizeRedirectUrl with newly vulnerable patterns --- + + @Test + void testSanitizeBlocksUrlEncodedBypass() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + String result = RedirectHelper.sanitizeRedirectUrl("%2F%2Fevil.com", headers); + assertEquals("https://app.appsmith.com/applications", result); + } + + @Test + void testSanitizeBlocksWhitespacePrefixedUrl() { + HttpHeaders headers = headersWithOrigin("https://app.appsmith.com"); + String result = RedirectHelper.sanitizeRedirectUrl(" https://evil.com", headers); + assertEquals("https://app.appsmith.com/applications", result); + } +} From 2cf526e95ea9d2bd1a7d0487f6b00b7398dd7806 Mon Sep 17 00:00:00 2001 From: Wyatt Walter Date: Fri, 20 Feb 2026 13:46:32 -0600 Subject: [PATCH 02/11] docs: add Helm chart docs for extraVolumes and extraVolumeMounts (#41571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `extraVolumes` and `extraVolumeMounts` to the deployment parameters table in the Helm chart README - Adds commented usage examples (emptyDir mounted at `/tmp`) in `values.yaml` for both parameters Follow-up to #41515, which introduced the parameters but didn't include documentation. ## Test plan - [x] Verify `values.yaml` comments render correctly and the example is valid YAML when uncommented - [x] Verify README table renders correctly on GitHub 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Documentation** * Added two new Helm chart parameters for configuring additional pod volumes and mounts. * Included example configurations to guide users in custom volume setup. Co-authored-by: Claude Opus 4.6 --- deploy/helm/README.md | 2 ++ deploy/helm/values.yaml | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/deploy/helm/README.md b/deploy/helm/README.md index d1b6109784ab..b339686f208b 100644 --- a/deploy/helm/README.md +++ b/deploy/helm/README.md @@ -95,6 +95,8 @@ The command uninstalls the release and removes all Kubernetes resources associat | `nodeSelector` | Node labels for pod assignment | `{}` | | `tolerations` | Tolerations for pod assignment | `[]` | | `affinity` | Affinity fod pod assignment | `{}` | +| `extraVolumes` | Additional volumes to add to the pod | `[]` | +| `extraVolumeMounts` | Additional volume mounts to add to the appsmith container | `[]` | #### Workload kind diff --git a/deploy/helm/values.yaml b/deploy/helm/values.yaml index 48db3ba19e69..4d0306903bc1 100644 --- a/deploy/helm/values.yaml +++ b/deploy/helm/values.yaml @@ -167,10 +167,18 @@ securityContext: {} # runAsUser: 1000 ## @param extraVolumes Additional volumes to add to the pod +## e.g: +## extraVolumes: +## - name: tmp-dir +## emptyDir: {} ## extraVolumes: [] ## @param extraVolumeMounts Additional volume mounts to add to the appsmith container +## e.g: +## extraVolumeMounts: +## - name: tmp-dir +## mountPath: /tmp ## extraVolumeMounts: [] From 2b702ee3d83f65fa16610da114e20b48fd00b5c4 Mon Sep 17 00:00:00 2001 From: Wyatt Walter Date: Fri, 20 Feb 2026 13:46:52 -0600 Subject: [PATCH 03/11] docs(helm): document annotations parameter in README (#41567) The `annotations` value (for adding annotations to the Deployment/StatefulSet resource) was added to values.yaml but was missing from the README parameters table. Added it to the deployment parameters section alongside `podAnnotations`. https://claude.ai/code/session_013oSUp3viPsj7rPsgXjLht5 ## Summary by CodeRabbit ## Release Notes * **Documentation** * Added new deployment configuration parameter for custom annotations on Deployment and StatefulSet resources, enabling users to add custom metadata during deployment configurations. Co-authored-by: Claude --- deploy/helm/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy/helm/README.md b/deploy/helm/README.md index b339686f208b..2db26c330c69 100644 --- a/deploy/helm/README.md +++ b/deploy/helm/README.md @@ -86,6 +86,7 @@ The command uninstalls the release and removes all Kubernetes resources associat | --------------------------- | --------------------------------------------------- | --------------- | | `strategyType` | Appsmith deployment strategy type | `RollingUpdate` | | `schedulerName` | Alternate scheduler | `""` | +| `annotations` | Annotations to add to the Deployment/StatefulSet resource | `{}` | | `podAnnotations` | Annotations for Appsmith pods | `{}` | | `podLabels` | Labels for Appsmith pods | `{}` | | `podSecurityContext` | Appsmith pods security context | `{}` | From 55ac824f8d42f934cc7a69f8abc52880a6ad39ef Mon Sep 17 00:00:00 2001 From: Manish Kumar <107841575+sondermanish@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:27:51 +0530 Subject: [PATCH 04/11] chore: CE compatibility pr for ee fix (#41572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description > [!TIP] > _Add a TL;DR when the description is longer than 500 words or extremely technical (helps the content, marketing, and DevRel team)._ > > _Please also include relevant motivation and context. List any dependencies that are required for this change. Add links to Notion, Figma or any other documents that might be relevant to the PR._ Fixes https://linear.app/appsmith/issue/APP-14832/support-ssh-keys-for-git-authentication-in-appsmith > [!WARNING] > _If no issue exists, please create an issue first, and check with the maintainers if the issue is valid._ ## Automation /ok-to-test tags="@tag.Git" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 49c032c98b8b29c3137f97d6ed17890c7fa84aa1 > Cypress dashboard. > Tags: `@tag.Git` > Spec: >
Mon, 23 Feb 2026 15:11:26 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit - New Features - Connect and Import modals now support using existing SSH keys or generating new ones, with key fetching, selection, and inclusion in submissions. - Integrated SSH Key Manager (behind a feature flag) and exposed SSH key data in Git flows. - Added context provider to enable SSH key workflows in the import experience. - Style - Updated UI copy for Git connection steps (e.g., “Enter Repository URL”, “Set Up SSH Key”, “Configure SSH Key”, and refined SSH URL instructions). - Tests - Adjusted tests to match updated UI text. --- app/client/src/ce/constants/messages.ts | 9 +- app/client/src/ce/entities/FeatureFlag.ts | 2 + app/client/src/ce/hooks/useSSHKeyManager.ts | 20 + .../src/ce/pages/Applications/index.tsx | 5 +- app/client/src/ee/hooks/useSSHKeyManager.ts | 2 + .../GitApplicationContextProvider.tsx | 14 + .../components/GitImportContextProvider.tsx | 69 +++ .../application/components/index.tsx | 1 + app/client/src/git/ce/constants/messages.tsx | 12 +- .../ConnectInitialize/GenerateSSH.test.tsx | 2 +- .../ConnectInitialize/index.test.tsx | 2 +- .../ConnectModal/ConnectInitialize/index.tsx | 65 ++- .../src/git/components/ConnectModal/index.tsx | 26 +- .../components/GitContextProvider/index.tsx | 30 ++ .../src/git/components/ImportModal/index.tsx | 24 + .../git/components/common/AddDeployKey.tsx | 452 ++++++++++++------ app/client/src/git/components/common/types.ts | 23 + .../src/git/requests/connectRequest.types.ts | 6 + .../git/requests/gitImportRequest.types.ts | 6 + 19 files changed, 603 insertions(+), 167 deletions(-) create mode 100644 app/client/src/ce/hooks/useSSHKeyManager.ts create mode 100644 app/client/src/ee/hooks/useSSHKeyManager.ts create mode 100644 app/client/src/git-artifact-helpers/application/components/GitImportContextProvider.tsx diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index 7e65c8c1309f..9a9640abeb18 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -1093,7 +1093,7 @@ export const ERROR_GIT_INVALID_REMOTE = () => // Git Connect V2 export const CHOOSE_A_GIT_PROVIDER_STEP = () => "Choose a Git provider"; -export const GENERATE_SSH_KEY_STEP = () => "Generate SSH key"; +export const GENERATE_SSH_KEY_STEP = () => "Configure SSH Key"; export const ADD_DEPLOY_KEY_STEP = () => "Add deploy key"; export const CHOOSE_GIT_PROVIDER_QUESTION = () => "To begin with, choose your Git service provider"; @@ -1114,19 +1114,18 @@ export const ERROR_REPO_NOT_EMPTY_MESSAGE = () => "Kindly create a new repository and provide its remote SSH URL here. We require an empty repository to continue."; export const READ_DOCS = () => "Read Docs"; export const COPY_SSH_URL_MESSAGE = () => - "To generate the SSH Key, in your repo, copy the Remote SSH URL & paste it in the input field below."; + "Copy the Remote SSH URL from your repository and paste it in the input field below."; export const REMOTE_URL_INPUT_LABEL = () => "Remote SSH URL"; export const HOW_TO_COPY_REMOTE_URL = () => "How to copy & paste SSH remote URL"; export const ERROR_SSH_KEY_MISCONF_TITLE = () => "SSH key misconfiguration"; export const ERROR_SSH_KEY_MISCONF_MESSAGE = () => "It seems that your SSH key hasn't been added to your repository. To proceed, please revisit the steps below and configure your SSH key correctly."; -export const ADD_DEPLOY_KEY_STEP_TITLE = () => - "Add deploy key & give write access"; +export const ADD_DEPLOY_KEY_STEP_TITLE = () => "Set Up SSH Key"; export const HOW_TO_ADD_DEPLOY_KEY = () => "How to paste SSH Key in repo and give write access?"; export const CONSENT_ADDED_DEPLOY_KEY = () => - "I've added the deploy key and gave it write access"; + "I confirm this SSH key has write access to the repository"; export const PREVIOUS_STEP = () => "Previous step"; export const GIT_AUTHOR = () => "Git author"; export const DISCONNECT_GIT = () => "Disconnect Git"; diff --git a/app/client/src/ce/entities/FeatureFlag.ts b/app/client/src/ce/entities/FeatureFlag.ts index aaeda00237e9..f6b682bf0c93 100644 --- a/app/client/src/ce/entities/FeatureFlag.ts +++ b/app/client/src/ce/entities/FeatureFlag.ts @@ -67,6 +67,7 @@ export const FEATURE_FLAG = { release_static_url_enabled: "release_static_url_enabled", release_window_dimensions_enabled: "release_window_dimensions_enabled", release_branding_logo_resize_enabled: "release_branding_logo_resize_enabled", + release_ssh_key_manager_enabled: "release_ssh_key_manager_enabled", } as const; export type FeatureFlag = keyof typeof FEATURE_FLAG; @@ -122,6 +123,7 @@ export const DEFAULT_FEATURE_FLAG_VALUE: FeatureFlags = { release_static_url_enabled: false, release_window_dimensions_enabled: false, release_branding_logo_resize_enabled: false, + release_ssh_key_manager_enabled: false, }; export const AB_TESTING_EVENT_KEYS = { diff --git a/app/client/src/ce/hooks/useSSHKeyManager.ts b/app/client/src/ce/hooks/useSSHKeyManager.ts new file mode 100644 index 000000000000..17a972a0c635 --- /dev/null +++ b/app/client/src/ce/hooks/useSSHKeyManager.ts @@ -0,0 +1,20 @@ +import noop from "lodash/noop"; +import type { SSHKeyOption } from "git/components/common/types"; + +export interface UseSSHKeyManagerReturn { + isSSHKeyManagerEnabled: boolean; + sshKeys: SSHKeyOption[] | null; + isSSHKeysLoading: boolean; + fetchSSHKeys: () => void; + onCreateSSHKey: () => void; +} + +export default function useSSHKeyManager(): UseSSHKeyManagerReturn { + return { + isSSHKeyManagerEnabled: false, + sshKeys: null, + isSSHKeysLoading: false, + fetchSSHKeys: noop, + onCreateSSHKey: noop, + }; +} diff --git a/app/client/src/ce/pages/Applications/index.tsx b/app/client/src/ce/pages/Applications/index.tsx index c418f4e16984..6ba8950a76ae 100644 --- a/app/client/src/ce/pages/Applications/index.tsx +++ b/app/client/src/ce/pages/Applications/index.tsx @@ -143,6 +143,7 @@ import { GitImportModal as NewGitImportModal, GitImportOverrideModal, } from "git"; +import { GitImportContextProvider } from "git-artifact-helpers/application/components"; import OldRepoLimitExceededErrorModal from "pages/Editor/gitSync/RepoLimitExceededErrorModal"; import { trackCurrentDomain } from "utils/multiOrgDomains"; import OrganizationDropdown from "components/OrganizationDropdown"; @@ -154,11 +155,11 @@ function GitModals() { const isGitModEnabled = useGitModEnabled(); return isGitModEnabled ? ( - <> + - + ) : ( <> diff --git a/app/client/src/ee/hooks/useSSHKeyManager.ts b/app/client/src/ee/hooks/useSSHKeyManager.ts new file mode 100644 index 000000000000..6f0b398448ca --- /dev/null +++ b/app/client/src/ee/hooks/useSSHKeyManager.ts @@ -0,0 +1,2 @@ +export { default } from "ce/hooks/useSSHKeyManager"; +export * from "ce/hooks/useSSHKeyManager"; diff --git a/app/client/src/git-artifact-helpers/application/components/GitApplicationContextProvider.tsx b/app/client/src/git-artifact-helpers/application/components/GitApplicationContextProvider.tsx index 97b17cb519f7..320be9902f7a 100644 --- a/app/client/src/git-artifact-helpers/application/components/GitApplicationContextProvider.tsx +++ b/app/client/src/git-artifact-helpers/application/components/GitApplicationContextProvider.tsx @@ -20,6 +20,7 @@ import { getCurrentAppWorkspace, } from "ee/selectors/selectedWorkspaceSelectors"; import applicationStatusTransformer from "../applicationStatusTransformer"; +import useSSHKeyManager from "ee/hooks/useSSHKeyManager"; interface GitApplicationContextProviderProps { children: React.ReactNode; @@ -66,6 +67,14 @@ export default function GitApplicationContextProvider({ dispatch(fetchAllApplicationsOfWorkspace()); }, [dispatch]); + const { + fetchSSHKeys, + isSSHKeyManagerEnabled, + isSSHKeysLoading, + onCreateSSHKey, + sshKeys, + } = useSSHKeyManager(); + return ( diff --git a/app/client/src/git-artifact-helpers/application/components/GitImportContextProvider.tsx b/app/client/src/git-artifact-helpers/application/components/GitImportContextProvider.tsx new file mode 100644 index 000000000000..803956f1fae7 --- /dev/null +++ b/app/client/src/git-artifact-helpers/application/components/GitImportContextProvider.tsx @@ -0,0 +1,69 @@ +import React, { useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { GitContextProvider } from "git"; +import { getWorkspaceIdForImport } from "ee/selectors/applicationSelectors"; +import { setWorkspaceIdForImport } from "ee/actions/applicationActions"; +import { getCurrentAppWorkspace } from "ee/selectors/selectedWorkspaceSelectors"; +import noop from "lodash/noop"; +import useSSHKeyManager from "ee/hooks/useSSHKeyManager"; + +interface GitImportContextProviderProps { + children: React.ReactNode; +} + +const NULL_NOOP = () => null; + +/** + * Lightweight GitContextProvider for the Applications page where ImportModal + * is rendered without a current artifact. Provides only SSH key manager data + * and workspace-level fields; artifact-specific fields default to null/noop. + */ +export default function GitImportContextProvider({ + children, +}: GitImportContextProviderProps) { + const dispatch = useDispatch(); + + const workspace = useSelector(getCurrentAppWorkspace); + const importWorkspaceId = useSelector(getWorkspaceIdForImport); + + const setImportWorkspaceIdCb = useCallback(() => { + if (workspace?.id) { + dispatch( + setWorkspaceIdForImport({ editorId: "", workspaceId: workspace.id }), + ); + } + }, [dispatch, workspace?.id]); + + const { + fetchSSHKeys, + isSSHKeyManagerEnabled, + isSSHKeysLoading, + onCreateSSHKey, + sshKeys, + } = useSSHKeyManager(); + + return ( + + {children} + + ); +} diff --git a/app/client/src/git-artifact-helpers/application/components/index.tsx b/app/client/src/git-artifact-helpers/application/components/index.tsx index 0a09bc61b18b..0fe90e7eebfb 100644 --- a/app/client/src/git-artifact-helpers/application/components/index.tsx +++ b/app/client/src/git-artifact-helpers/application/components/index.tsx @@ -1 +1,2 @@ export { default as GitApplicationContextProvider } from "./GitApplicationContextProvider"; +export { default as GitImportContextProvider } from "./GitImportContextProvider"; diff --git a/app/client/src/git/ce/constants/messages.tsx b/app/client/src/git/ce/constants/messages.tsx index 24d183fb7581..2b03ca2fb6d9 100644 --- a/app/client/src/git/ce/constants/messages.tsx +++ b/app/client/src/git/ce/constants/messages.tsx @@ -15,11 +15,11 @@ export const IMPORT_OVERRIDE_MODAL = { export const CONNECT_GIT = { MODAL_TITLE: "Configure Git", CHOOSE_PROVIDER_CTA: "Configure Git", - GENERATE_SSH_KEY_CTA: "Generate SSH key", + GENERATE_SSH_KEY_CTA: "Continue", CONNECT_CTA: "Connect Git", CHOOSE_PROVIDER_STEP_TITLE: "Choose a Git provider", - GENERATE_SSH_KEY_STEP_TITLE: "Generate SSH key", - ADD_DEPLOY_KEY_STEP_TITLE: "Add deploy key", + GENERATE_SSH_KEY_STEP_TITLE: "Enter Repository URL", + ADD_DEPLOY_KEY_STEP_TITLE: "Set Up SSH Key", WAIT_TEXT: "Please wait while we connect to Git...", PREV_STEP: "Previous step", }; @@ -51,6 +51,12 @@ export const RELEASE_NOTES_INPUT = { PLACEHOLDER: "Your release notes here", }; +export const ARTIFACT_SSH_KEY_MANAGER = { + NO_KEYS_TITLE: "", + NO_KEYS_DESCRIPTION: "", + CREATE_KEY_CTA: "", +}; + export const LATEST_COMMIT_INFO = { TITLE: "Commit", LOADING_COMMIT_MESSAGE: "Fetching latest commit...", diff --git a/app/client/src/git/components/ConnectModal/ConnectInitialize/GenerateSSH.test.tsx b/app/client/src/git/components/ConnectModal/ConnectInitialize/GenerateSSH.test.tsx index 7363a6890442..c66f4b7bfedc 100644 --- a/app/client/src/git/components/ConnectModal/ConnectInitialize/GenerateSSH.test.tsx +++ b/app/client/src/git/components/ConnectModal/ConnectInitialize/GenerateSSH.test.tsx @@ -26,7 +26,7 @@ describe("GenerateSSH Component", () => { it("renders the component correctly", () => { render(); - expect(screen.getByText("Generate SSH key")).toBeInTheDocument(); + expect(screen.getByText("Configure SSH Key")).toBeInTheDocument(); expect( screen.getByTestId("t--git-connect-remote-input"), ).toBeInTheDocument(); diff --git a/app/client/src/git/components/ConnectModal/ConnectInitialize/index.test.tsx b/app/client/src/git/components/ConnectModal/ConnectInitialize/index.test.tsx index 62948c11c124..4da5a7d27f4a 100644 --- a/app/client/src/git/components/ConnectModal/ConnectInitialize/index.test.tsx +++ b/app/client/src/git/components/ConnectModal/ConnectInitialize/index.test.tsx @@ -111,7 +111,7 @@ describe("ConnectModal Component", () => { completeGenerateSSHKeyStep(); expect( - screen.getByText("Add deploy key & give write access"), + screen.getByRole("heading", { name: "Set Up SSH Key" }), ).toBeInTheDocument(); }); diff --git a/app/client/src/git/components/ConnectModal/ConnectInitialize/index.tsx b/app/client/src/git/components/ConnectModal/ConnectInitialize/index.tsx index 8e3d99c2aa84..54e2190f4257 100644 --- a/app/client/src/git/components/ConnectModal/ConnectInitialize/index.tsx +++ b/app/client/src/git/components/ConnectModal/ConnectInitialize/index.tsx @@ -17,6 +17,7 @@ import type { ConnectFormDataState } from "../../common/types"; import type { GitImportRequestParams } from "git/requests/gitImportRequest.types"; import { GitErrorCodes } from "git/constants/enums"; import { CONNECT_GIT, IMPORT_GIT } from "git/ee/constants/messages"; +import type { SSHKeyOption } from "../../common/types"; const OFFSET = 200; const OUTER_PADDING = 32; @@ -70,15 +71,45 @@ export interface ConnectInitializeProps { onOpenImport: (() => void) | null; onSubmit: (params: ConnectRequestParams | GitImportRequestParams) => void; sshPublicKey: string | null; + /** + * Whether the SSH key manager feature is enabled + */ + isSSHKeyManagerEnabled?: boolean; + /** + * List of available SSH keys from the SSH key manager + */ + availableSSHKeys?: SSHKeyOption[]; + /** + * Whether the SSH keys list is loading + */ + isSSHKeysLoading?: boolean; + /** + * Current user's email (to determine key ownership) + */ + currentUserEmail?: string; + /** + * Callback to fetch SSH keys (called only when user chooses "Use existing key") + */ + onFetchSSHKeys?: () => void; + /** + * Callback to navigate to SSH key creation (shown when no keys exist) + */ + onCreateSSHKey?: () => void; } function ConnectInitialize({ artifactType, + availableSSHKeys = [], + currentUserEmail, error = null, isImport = false, isSSHKeyLoading = false, + isSSHKeyManagerEnabled = false, + isSSHKeysLoading = false, isSubmitLoading = false, + onCreateSSHKey = noop, onFetchSSHKey = noop, + onFetchSSHKeys = noop, onGenerateSSHKey = noop, onOpenImport = null, onSubmit = noop, @@ -99,6 +130,8 @@ function ConnectInitialize({ remoteUrl: undefined, isAddedDeployKey: false, sshKeyType: "ECDSA", + sshKeySource: "generate", + sshKeyId: undefined, }); const [activeStep, setActiveStep] = useState( @@ -159,25 +192,17 @@ function ConnectInitialize({ }; if (formData.remoteUrl) { - onSubmit({ + const params: ConnectRequestParams | GitImportRequestParams = { remoteUrl: formData.remoteUrl, gitProfile, - }); - // if (!isImport) { - // AnalyticsUtil.logEvent( - // "GS_CONNECT_BUTTON_ON_GIT_SYNC_MODAL_CLICK", - // { repoUrl: formData?.remoteUrl, connectFlow: "v2" }, - // ); - // connect({ - // remoteUrl: formData.remoteUrl, - // gitProfile, - // }); - // } else { - // gitImport({ - // remoteUrl: formData.remoteUrl, - // gitProfile, - // }); - // } + }; + + // Include sshKeyId if using an existing key + if (formData.sshKeySource === "existing" && formData.sshKeyId) { + params.sshKeyId = formData.sshKeyId; + } + + onSubmit(params); } break; @@ -222,11 +247,17 @@ function ConnectInitialize({ )} {activeStep === GIT_CONNECT_STEPS.ADD_DEPLOY_KEY && ( { + toggleConnectModal(false); + onCreateSSHKeyNav(); + }, [toggleConnectModal, onCreateSSHKeyNav]); + const resetConnectState = useCallback(() => { resetConnect(); resetFetchSSHKey(); @@ -63,12 +79,18 @@ function ConnectModal() { return ( StatusTreeStruct[] | null; + + // SSH key manager + sshKeys: SSHKeyOption[] | null; + isSSHKeysLoading: boolean; + fetchSSHKeys: () => void; + isSSHKeyManagerEnabled: boolean; + onCreateSSHKey: () => void; } const gitContextInitialValue = {} as GitContextValue; @@ -57,6 +65,13 @@ interface GitContextProviderProps { status: FetchStatusResponseData, ) => StatusTreeStruct[] | null; + // SSH key manager + sshKeys?: SSHKeyOption[] | null; + isSSHKeysLoading?: boolean; + fetchSSHKeys?: () => void; + isSSHKeyManagerEnabled?: boolean; + onCreateSSHKey?: () => void; + // children children: React.ReactNode; } @@ -70,12 +85,17 @@ export default function GitContextProvider({ baseArtifactId = null, children, fetchArtifacts = noop, + fetchSSHKeys = noop, importWorkspaceId = null, isConnectPermitted = false, isManageAutocommitPermitted = false, isManageDefaultBranchPermitted = false, isManageProtectedBranchesPermitted = false, + isSSHKeyManagerEnabled = false, + isSSHKeysLoading = false, + onCreateSSHKey = noop, setImportWorkspaceId = noop, + sshKeys = null, statusTransformer = NULL_NOOP, workspace = null, }: GitContextProviderProps) { @@ -101,6 +121,11 @@ export default function GitContextProvider({ isManageDefaultBranchPermitted, isManageProtectedBranchesPermitted, statusTransformer, + sshKeys, + isSSHKeysLoading, + fetchSSHKeys, + isSSHKeyManagerEnabled, + onCreateSSHKey, }), [ artifactDef, @@ -115,6 +140,11 @@ export default function GitContextProvider({ isManageDefaultBranchPermitted, isManageProtectedBranchesPermitted, statusTransformer, + sshKeys, + isSSHKeysLoading, + fetchSSHKeys, + isSSHKeyManagerEnabled, + onCreateSSHKey, ], ); diff --git a/app/client/src/git/components/ImportModal/index.tsx b/app/client/src/git/components/ImportModal/index.tsx index 31932d55d925..8909873273dc 100644 --- a/app/client/src/git/components/ImportModal/index.tsx +++ b/app/client/src/git/components/ImportModal/index.tsx @@ -1,8 +1,11 @@ import React, { useCallback } from "react"; +import { useSelector } from "react-redux"; import ConnectModalView from "../ConnectModal/ConnectModalView"; import type { GitImportRequestParams } from "git/requests/gitImportRequest.types"; import useImport from "git/hooks/useImport"; import useGlobalSSHKey from "git/hooks/useGlobalSSHKey"; +import { useGitContext } from "../GitContextProvider"; +import { getCurrentUser } from "selectors/usersSelectors"; import noop from "lodash/noop"; function ImportModal() { @@ -21,6 +24,16 @@ function ImportModal() { resetGlobalSSHKey, } = useGlobalSSHKey(); + const { + fetchSSHKeys, + isSSHKeyManagerEnabled, + isSSHKeysLoading, + onCreateSSHKey: onCreateSSHKeyNav, + sshKeys, + } = useGitContext(); + + const currentUser = useSelector(getCurrentUser); + const sshPublicKey = globalSSHKey?.publicKey ?? null; const onSubmit = useCallback( @@ -30,6 +43,11 @@ function ImportModal() { [gitImport], ); + const handleCreateSSHKey = useCallback(() => { + toggleImportModal(false); + onCreateSSHKeyNav(); + }, [toggleImportModal, onCreateSSHKeyNav]); + const resetConnectState = useCallback(() => { resetGlobalSSHKey(); resetGitImport(); @@ -38,12 +56,18 @@ function ImportModal() { return ( void; sshPublicKey: string | null; value: Partial | null; + /** + * Whether the SSH key manager feature is enabled + */ + isSSHKeyManagerEnabled?: boolean; + /** + * List of available SSH keys from the SSH key manager + */ + availableSSHKeys?: SSHKeyOption[]; + /** + * Whether the SSH keys list is loading + */ + isSSHKeysLoading?: boolean; + /** + * Current user's email (to determine key ownership) + */ + currentUserEmail?: string; + /** + * Callback to fetch SSH keys (called only when user chooses "Use existing key") + */ + onFetchSSHKeys?: () => void; + /** + * Callback to navigate to SSH key creation (shown when no keys exist) + */ + onCreateSSHKey?: () => void; } function AddDeployKey({ + availableSSHKeys = [], + currentUserEmail, error = null, isSSHKeyLoading = false, + isSSHKeyManagerEnabled = false, + isSSHKeysLoading = false, isSubmitLoading = false, onChange = noop, + onCreateSSHKey = noop, onFetchSSHKey = noop, + onFetchSSHKeys = noop, onGenerateSSHKey = noop, sshPublicKey = null, value = null, @@ -157,28 +143,33 @@ function AddDeployKey({ const [fetched, setFetched] = useState(false); const [keyType, setKeyType] = useState(); + const sshKeySource: SSHKeySource = value?.sshKeySource || "generate"; + const selectedSSHKeyId = value?.sshKeyId; + + // Get the selected SSH key's public key for display + const selectedSSHKey = availableSSHKeys.find( + (key) => key.id === selectedSSHKeyId, + ); + const displayPublicKey = + sshKeySource === "existing" + ? selectedSSHKey?.gitAuth.publicKey ?? null + : sshPublicKey; + useEffect( function fetchKeyPairOnInitEffect() { - if (!fetched) { + // Only fetch deploy key if using "generate" mode + if (!fetched && sshKeySource === "generate") { onFetchSSHKey(); setFetched(true); - // doesn't support callback anymore - // fetchSSHKey({ - // onSuccessCallback: () => { - // setFetched(true); - // }, - // onErrorCallback: () => { - // setFetched(true); - // }, - // }); } }, - [fetched, onFetchSSHKey], + [fetched, onFetchSSHKey, sshKeySource], ); useEffect( function setSSHKeyTypeonInitEffect() { - if (fetched && !isSSHKeyLoading) { + // Only set key type for "generate" mode + if (sshKeySource === "generate" && fetched && !isSSHKeyLoading) { if (sshPublicKey && sshPublicKey.includes("rsa")) { setKeyType("RSA"); } else if ( @@ -192,25 +183,58 @@ function AddDeployKey({ } } }, - [fetched, sshPublicKey, value?.remoteUrl, isSSHKeyLoading], + [fetched, sshPublicKey, value?.remoteUrl, isSSHKeyLoading, sshKeySource], ); useEffect( function generateSSHOnInitEffect() { + // Only generate for "generate" mode if ( - (keyType && !sshPublicKey) || - (keyType && !sshPublicKey?.includes(keyType.toLowerCase())) + sshKeySource === "generate" && + ((keyType && !sshPublicKey) || + (keyType && !sshPublicKey?.includes(keyType.toLowerCase()))) ) { onGenerateSSHKey(keyType); - // doesn't support callback anymore - // generateSSHKey(keyType, { - // onSuccessCallback: () => { - // toast.show("SSH Key generated successfully", { kind: "success" }); - // }, - // }); } }, - [keyType, sshPublicKey, onGenerateSSHKey], + [keyType, sshPublicKey, onGenerateSSHKey, sshKeySource], + ); + + const handleSSHKeySourceChange = useCallback( + (newSource: string) => { + const source = newSource as SSHKeySource; + + onChange({ + sshKeySource: source, + // Clear sshKeyId when switching to generate + sshKeyId: source === "generate" ? undefined : value?.sshKeyId, + // Reset the deploy key confirmation when switching + isAddedDeployKey: false, + }); + + // If switching to generate mode and haven't fetched yet, trigger fetch for deploy key + if (source === "generate" && !fetched) { + onFetchSSHKey(); + setFetched(true); + } + + // If switching to existing mode, fetch SSH keys from the manager + if (source === "existing") { + onFetchSSHKeys(); + } + }, + [onChange, value?.sshKeyId, fetched, onFetchSSHKey, onFetchSSHKeys], + ); + + const handleSSHKeySelect = useCallback( + (keyId: string) => { + onChange({ + sshKeyId: keyId, + // Reset the deploy key confirmation when changing key + isAddedDeployKey: false, + }); + }, + [onChange], ); const repositorySettingsUrl = getRepositorySettingsUrl( @@ -231,6 +255,23 @@ function AddDeployKey({ [onChange], ); + const renderRepositorySettings = () => { + if (!!repositorySettingsUrl && value?.gitProvider !== "others") { + return ( + + repository settings. + + ); + } + + return "repository settings."; + }; + return ( <> {error && @@ -274,78 +315,217 @@ function AddDeployKey({ - - Copy below SSH key and paste it in your{" "} - {!!repositorySettingsUrl && value?.gitProvider !== "others" ? ( - + {/* SSH Key Source Selection - only show when SSH key manager is enabled */} + {isSSHKeyManagerEnabled && ( + - repository settings. - - ) : ( - "repository settings." - )}{" "} - Now, give write access to it. - - - triggerNode.parentNode} - onChange={setKeyType} - size="sm" - value={keyType} - > - - - - {!isSSHKeyLoading ? ( - - - {keyType} - {sshPublicKey} - {!isSubmitLoading && ( - Use existing SSH key + Generate new deploy key + + )} + + {/* Existing SSH Key Selection */} + {isSSHKeyManagerEnabled && + sshKeySource === "existing" && + (availableSSHKeys.length > 0 ? ( + + Select SSH Key + + + ) : ( + !isSSHKeysLoading && ( + + + + + {ARTIFACT_SSH_KEY_MANAGER.NO_KEYS_TITLE} + + + {ARTIFACT_SSH_KEY_MANAGER.NO_KEYS_DESCRIPTION} + + + + + ) + ))} + + {(sshKeySource === "generate" || displayPublicKey) && ( + <> + + Copy below SSH key and paste it in your{" "} + {renderRepositorySettings()} Now, give write access to it. + + + + {sshKeySource === "generate" && ( + + triggerNode.parentNode + } + isDisabled={isSubmitLoading} + onChange={setKeyType} + size="sm" + value={keyType} + > + + + + )} + {!(sshKeySource === "generate" + ? isSSHKeyLoading + : isSSHKeysLoading) ? ( + + + + + {sshKeySource === "existing" + ? selectedSSHKey?.keyType + : keyType} + + + {displayPublicKey} + + {!isSubmitLoading && ( + + )} + + + ) : ( + + )} + + + + )} + {value?.gitProvider !== "others" && sshKeySource === "generate" && ( + + + + {createMessage(HOW_TO_ADD_DEPLOY_KEY)} + + + - )} - - ) : ( - + + )} - - {value?.gitProvider !== "others" && ( - - - - {createMessage(HOW_TO_ADD_DEPLOY_KEY)} - - - - - - )} + - + {createMessage(CONSENT_ADDED_DEPLOY_KEY)} -  * + * - + ); diff --git a/app/client/src/git/components/common/types.ts b/app/client/src/git/components/common/types.ts index 91ea222f69f2..a5170be6ba38 100644 --- a/app/client/src/git/components/common/types.ts +++ b/app/client/src/git/components/common/types.ts @@ -2,6 +2,21 @@ import type { GIT_PROVIDERS } from "./constants"; export type GitProvider = (typeof GIT_PROVIDERS)[number]; +export type SSHKeySource = "existing" | "generate"; + +/** + * Minimal SSH key shape needed by git components. + * Kept here so git/ doesn't import from ee/. + * The full SSHKey type in ee/types/sshKeysTypes is structurally compatible. + */ +export interface SSHKeyOption { + id: string; + name: string; + email: string; + keyType: string; + gitAuth: { publicKey: string }; +} + export interface ConnectFormDataState { gitProvider?: GitProvider; gitEmptyRepoExists?: string; @@ -9,4 +24,12 @@ export interface ConnectFormDataState { remoteUrl?: string; isAddedDeployKey?: boolean; sshKeyType?: "RSA" | "ECDSA"; + /** + * The source of SSH key to use: "existing" (from SSH key manager) or "generate" (new deploy key) + */ + sshKeySource?: SSHKeySource; + /** + * ID of the existing SSH key to use (when sshKeySource is "existing") + */ + sshKeyId?: string; } diff --git a/app/client/src/git/requests/connectRequest.types.ts b/app/client/src/git/requests/connectRequest.types.ts index d47cc4114b21..3bb6e71b2e03 100644 --- a/app/client/src/git/requests/connectRequest.types.ts +++ b/app/client/src/git/requests/connectRequest.types.ts @@ -8,6 +8,12 @@ export interface ConnectRequestParams { authorEmail: string; useDefaultProfile?: boolean; }; + /** + * Optional ID of an existing SSH key to use for this connection. + * If provided, the server will use this key instead of the artifact's generated key. + * The key must be owned by or shared with the current user. + */ + sshKeyId?: string; } export interface ConnectResponseData extends ApplicationPayload {} diff --git a/app/client/src/git/requests/gitImportRequest.types.ts b/app/client/src/git/requests/gitImportRequest.types.ts index 6c2b599c6103..f0beb765f660 100644 --- a/app/client/src/git/requests/gitImportRequest.types.ts +++ b/app/client/src/git/requests/gitImportRequest.types.ts @@ -10,6 +10,12 @@ export interface GitImportRequestParams { useDefaultProfile?: boolean; }; override?: boolean; + /** + * Optional ID of an existing SSH key to use for this import. + * If provided, the server will use this key instead of generating a new one. + * The key must be owned by or shared with the current user. + */ + sshKeyId?: string; } export interface GitImportResponseData { From 992f911c781c85563d34ae5b473600f4bc5dbfab Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Wed, 25 Feb 2026 16:47:32 -0500 Subject: [PATCH 05/11] feat: add headerRowColor, oddRowColor, evenRowColor style properties to TableWidgetV2 (#41551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add widget-level row color style properties that allow users to customize header, odd, and even row background colors via the Style tab color pickers. Colors are applied via CSS classes on the TableWrapper styled component, with correct priority below selected-row/hover/cell-level overrides. ## Description Alternate row colors on tables Fixes #`8923` _or_ Fixes `Issue URL` > [!WARNING] > _If no issue exists, please create an issue first, and check with the maintainers if the issue is valid._ ## Automation /ok-to-test tags="@tag.All" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 5f7961b9ba56fd4df3d4fc8156ca39ebbdc97b60 > Cypress dashboard. > Tags: `@tag.All` > Spec: >
Mon, 16 Feb 2026 23:08:48 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [X] No ## Summary by CodeRabbit ## Release Notes * **New Features** * Table widgets now support custom row and header colors. Users can configure header row background, header text, odd row background, and even row background colors for enhanced visual customization. * **Refactor** * Optimized table row styling logic and removed unused code expressions. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: subrata71 Co-authored-by: Cursor --- .../widgets/TableWidgetV2/component/Table.tsx | 10 ++-- .../component/TableBodyCoreComponents/Row.tsx | 13 +++-- .../component/TableStyledWrappers.tsx | 51 +++++++++++-------- .../widgets/TableWidgetV2/component/index.tsx | 18 +++++-- .../widgets/TableWidgetV2/component/types.ts | 10 +--- .../src/widgets/TableWidgetV2/constants.ts | 10 +++- .../widgets/TableWidgetV2/widget/index.tsx | 4 ++ .../widget/propertyConfig/styleConfig.ts | 40 +++++++++++++++ 8 files changed, 114 insertions(+), 42 deletions(-) diff --git a/app/client/src/widgets/TableWidgetV2/component/Table.tsx b/app/client/src/widgets/TableWidgetV2/component/Table.tsx index 7b56dea7379b..ef3f9e94c99e 100644 --- a/app/client/src/widgets/TableWidgetV2/component/Table.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/Table.tsx @@ -238,12 +238,6 @@ export function Table(props: TableProps) { ], ); - props.isVisibleSearch || - props.isVisibleFilters || - props.isVisibleDownload || - props.isVisiblePagination || - props.allowAddNewRow; - /** * What this really translates is to fixed height rows: * shouldUseVirtual: false -> fixed height row, irrespective of content small or big @@ -331,12 +325,16 @@ export function Table(props: TableProps) { borderRadius={props.borderRadius} borderWidth={props.borderWidth} boxShadow={props.boxShadow} + evenRowColor={props.evenRowColor} + headerRowColor={props.headerRowColor} + headerTextColor={props.headerTextColor} height={props.height} id={`table${props.widgetId}`} isAddRowInProgress={props.isAddRowInProgress} isHeaderVisible={isHeaderVisible} isResizingColumn={isResizingColumn.current} multiRowSelection={props.multiRowSelection} + oddRowColor={props.oddRowColor} tableSizes={tableSizes} triggerRowSelection={props.triggerRowSelection} variant={props.variant} diff --git a/app/client/src/widgets/TableWidgetV2/component/TableBodyCoreComponents/Row.tsx b/app/client/src/widgets/TableWidgetV2/component/TableBodyCoreComponents/Row.tsx index 95281c1191de..fde846fb52b5 100644 --- a/app/client/src/widgets/TableWidgetV2/component/TableBodyCoreComponents/Row.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/TableBodyCoreComponents/Row.tsx @@ -1,5 +1,6 @@ import type { Key } from "react"; import React, { useEffect, useRef, useState } from "react"; +import classNames from "classnames"; import type { Row as ReactTableRowType } from "react-table"; import type { ListChildComponentProps, VariableSizeList } from "react-window"; import { renderBodyCheckBoxCell } from "../cellComponents/SelectionCheckboxCell"; @@ -77,12 +78,18 @@ export function Row(props: RowType) { rowProps["role"] = "button"; } + const rowClassName = classNames( + "tr", + props.row.index % 2 === 0 ? "odd-row" : "even-row", + isRowSelected && "selected-row", + props.className, + isAddRowInProgress && props.index === 0 && "new-row", + ); + return (
{ diff --git a/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx b/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx index f22db0b4d79f..f309c5821ea7 100644 --- a/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx @@ -24,30 +24,32 @@ import { hideScrollbar, invisible } from "constants/DefaultTheme"; import { lightenColor, darkenColor } from "widgets/WidgetUtils"; import { FontStyleTypes } from "constants/WidgetConstants"; import { Classes } from "@blueprintjs/core"; -import type { TableVariant } from "../constants"; +import type { RowColorStyles, TableVariant } from "../constants"; import { TableVariantTypes } from "../constants"; import { Layers } from "constants/Layers"; const BORDER_RADIUS = "border-radius: 4px;"; const HEADER_CONTROL_FONT_SIZE = "12px"; -export const TableWrapper = styled.div<{ - width: number; - height: number; - tableSizes: TableSizes; - accentColor: string; - backgroundColor?: Color; - triggerRowSelection: boolean; - isHeaderVisible?: boolean; - borderRadius: string; - boxShadow?: string; - borderColor?: string; - borderWidth?: number; - isResizingColumn?: boolean; - variant?: TableVariant; - isAddRowInProgress: boolean; - multiRowSelection?: boolean; -}>` +export const TableWrapper = styled.div< + RowColorStyles & { + width: number; + height: number; + tableSizes: TableSizes; + accentColor: string; + backgroundColor?: Color; + triggerRowSelection: boolean; + isHeaderVisible?: boolean; + borderRadius: string; + boxShadow?: string; + borderColor?: string; + borderWidth?: number; + isResizingColumn?: boolean; + variant?: TableVariant; + isAddRowInProgress: boolean; + multiRowSelection?: boolean; + } +>` width: 100%; height: 100%; background: white; @@ -114,6 +116,12 @@ export const TableWrapper = styled.div<{ .tr { cursor: ${(props) => props.triggerRowSelection && "pointer"}; background: ${Colors.WHITE}; + &.odd-row { + background: ${(props) => props.oddRowColor || Colors.WHITE}; + } + &.even-row { + background: ${(props) => props.evenRowColor || Colors.WHITE}; + } &.selected-row { background: ${({ accentColor }) => `${lightenColor(accentColor)}`} !important; @@ -196,7 +204,7 @@ export const TableWrapper = styled.div<{ props.isHeaderVisible ? props.tableSizes.COLUMN_HEADER_HEIGHT : 40}px; line-height: ${(props) => props.isHeaderVisible ? props.tableSizes.COLUMN_HEADER_HEIGHT : 40}px; - background: var(--wds-color-bg); + background: ${(props) => props.headerRowColor || "var(--wds-color-bg)"}; font-weight: bold; } .td { @@ -222,7 +230,8 @@ export const TableWrapper = styled.div<{ } [role="columnheader"] { - background-color: var(--wds-color-bg) !important; + background-color: ${(props) => + props.headerRowColor || "var(--wds-color-bg)"} !important; } [data-sticky-td] { @@ -279,7 +288,7 @@ export const TableWrapper = styled.div<{ width: 100%; text-overflow: ellipsis; overflow: hidden; - color: ${Colors.OXFORD_BLUE}; + color: ${(props) => props.headerTextColor || Colors.OXFORD_BLUE}; padding-left: 10px; &.sorted { padding-left: 5px; diff --git a/app/client/src/widgets/TableWidgetV2/component/index.tsx b/app/client/src/widgets/TableWidgetV2/component/index.tsx index 069eb305b53d..492832bdeaf9 100644 --- a/app/client/src/widgets/TableWidgetV2/component/index.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/index.tsx @@ -13,7 +13,7 @@ import Table from "./Table"; import type { EventType } from "constants/AppsmithActionConstants/ActionConstants"; import equal from "fast-deep-equal/es6"; import { useCallback } from "react"; -import type { EditableCell, TableVariant } from "../constants"; +import type { EditableCell, RowColorStyles, TableVariant } from "../constants"; import { ColumnTypes } from "../constants"; export interface ColumnMenuOptionProps { @@ -38,7 +38,7 @@ export interface ColumnMenuSubOptionProps { isHeader?: boolean; } -interface ReactTableComponentProps { +interface ReactTableComponentProps extends RowColorStyles { widgetId: string; widgetName: string; searchKey: string; @@ -129,10 +129,13 @@ function ReactTableComponent(props: ReactTableComponentProps) { editableCell, editMode, endOfData, + evenRowColor, filters, handleColumnFreeze, handleReorderColumn, handleResizeColumn, + headerRowColor, + headerTextColor, height, isAddRowInProgress, isInfiniteScrollEnabled, @@ -144,6 +147,7 @@ function ReactTableComponent(props: ReactTableComponentProps) { isVisibleSearch, multiRowSelection, nextPageClick, + oddRowColor, onAddNewRow, onAddNewRowAction, onBulkEditDiscard, @@ -257,10 +261,13 @@ function ReactTableComponent(props: ReactTableComponentProps) { editableCell={editableCell} enableDrag={memoziedEnableDrag} endOfData={endOfData} + evenRowColor={evenRowColor} filters={filters} handleColumnFreeze={handleColumnFreeze} handleReorderColumn={handleReorderColumn} handleResizeColumn={handleResizeColumn} + headerRowColor={headerRowColor} + headerTextColor={headerTextColor} height={height} isAddRowInProgress={isAddRowInProgress} isInfiniteScrollEnabled={isInfiniteScrollEnabled} @@ -272,6 +279,7 @@ function ReactTableComponent(props: ReactTableComponentProps) { isVisibleSearch={isVisibleSearch} multiRowSelection={multiRowSelection} nextPageClick={nextPageClick} + oddRowColor={oddRowColor} onAddNewRow={onAddNewRow} onAddNewRowAction={onAddNewRowAction} onBulkEditDiscard={onBulkEditDiscard} @@ -340,6 +348,11 @@ export default React.memo(ReactTableComponent, (prev, next) => { prev.borderWidth === next.borderWidth && prev.borderColor === next.borderColor && prev.accentColor === next.accentColor && + prev.headerRowColor === next.headerRowColor && + prev.headerTextColor === next.headerTextColor && + prev.oddRowColor === next.oddRowColor && + prev.evenRowColor === next.evenRowColor && + prev.variant === next.variant && //shallow equal possible equal(prev.columnWidthMap, next.columnWidthMap) && //static reference @@ -348,7 +361,6 @@ export default React.memo(ReactTableComponent, (prev, next) => { // and we are not changing the columns manually. prev.columns === next.columns && equal(prev.editableCell, next.editableCell) && - prev.variant === next.variant && prev.primaryColumnId === next.primaryColumnId && equal(prev.isEditableCellsValid, next.isEditableCellsValid) && prev.isAddRowInProgress === next.isAddRowInProgress && diff --git a/app/client/src/widgets/TableWidgetV2/component/types.ts b/app/client/src/widgets/TableWidgetV2/component/types.ts index 4f94f4aa1f54..b2888f44c327 100644 --- a/app/client/src/widgets/TableWidgetV2/component/types.ts +++ b/app/client/src/widgets/TableWidgetV2/component/types.ts @@ -1,7 +1,6 @@ import type { EventType } from "constants/AppsmithActionConstants/ActionConstants"; -import type { ReactNode } from "react"; import type { Row as ReactTableRowType } from "react-table"; -import type { EditableCell, TableVariant } from "../constants"; +import type { EditableCell, RowColorStyles, TableVariant } from "../constants"; import type { AddNewRowActions, CompactMode, @@ -10,7 +9,7 @@ import type { StickyType, } from "./Constants"; -export interface TableProps { +export interface TableProps extends RowColorStyles { width: number; height: number; pageSize: number; @@ -83,8 +82,3 @@ export interface TableProps { endOfData: boolean; cachedTableData: Array>; } - -export interface TableProviderProps extends TableProps { - children: ReactNode; - currentPageIndex: number; -} diff --git a/app/client/src/widgets/TableWidgetV2/constants.ts b/app/client/src/widgets/TableWidgetV2/constants.ts index 9c8283501b4d..a4a5755402c0 100644 --- a/app/client/src/widgets/TableWidgetV2/constants.ts +++ b/app/client/src/widgets/TableWidgetV2/constants.ts @@ -15,6 +15,13 @@ import type { IconName } from "@blueprintjs/icons"; import type { ButtonVariant } from "components/constants"; import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; +export interface RowColorStyles { + headerRowColor?: string; + headerTextColor?: string; + oddRowColor?: string; + evenRowColor?: string; +} + export interface EditableCell { column: string; index: number; @@ -52,7 +59,8 @@ export interface TableWidgetProps extends WidgetProps, WithMeta, TableStyles, - AddNewRowProps { + AddNewRowProps, + RowColorStyles { pristine: boolean; nextPageKey?: string; prevPageKey?: string; diff --git a/app/client/src/widgets/TableWidgetV2/widget/index.tsx b/app/client/src/widgets/TableWidgetV2/widget/index.tsx index 2e865783bec6..0c94a3c05fe3 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/index.tsx +++ b/app/client/src/widgets/TableWidgetV2/widget/index.tsx @@ -1344,10 +1344,13 @@ class TableWidgetV2 extends BaseWidget { editMode={this.props.renderMode === RenderModes.CANVAS} editableCell={this.props.editableCell} endOfData={this.props.endOfData} + evenRowColor={this.props.evenRowColor} filters={this.props.filters} handleColumnFreeze={this.handleColumnFreeze} handleReorderColumn={this.handleReorderColumn} handleResizeColumn={this.handleResizeColumn} + headerRowColor={this.props.headerRowColor} + headerTextColor={this.props.headerTextColor} height={componentHeight} isAddRowInProgress={this.props.isAddRowInProgress} isEditableCellsValid={this.props.isEditableCellsValid} @@ -1366,6 +1369,7 @@ class TableWidgetV2 extends BaseWidget { this.props.multiRowSelection && !this.props.isAddRowInProgress } nextPageClick={this.handleNextPageClick} + oddRowColor={this.props.oddRowColor} onAddNewRow={this.handleAddNewRowClick} onAddNewRowAction={this.handleAddNewRowAction} onBulkEditDiscard={this.onBulkEditDiscard} diff --git a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/styleConfig.ts b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/styleConfig.ts index 98f5c15b88d1..c2487d970d18 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/styleConfig.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/styleConfig.ts @@ -173,6 +173,46 @@ export default [ isTriggerProperty: false, validation: { type: ValidationTypes.TEXT }, }, + { + propertyName: "headerRowColor", + label: "Header row color", + helpText: "Changes the background color of the header row", + controlType: "COLOR_PICKER", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + }, + { + propertyName: "headerTextColor", + label: "Header text color", + helpText: "Changes the text color of the header row", + controlType: "COLOR_PICKER", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + }, + { + propertyName: "oddRowColor", + label: "Odd row color", + helpText: "Changes the background color of odd rows", + controlType: "COLOR_PICKER", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + }, + { + propertyName: "evenRowColor", + label: "Even row color", + helpText: "Changes the background color of even rows", + controlType: "COLOR_PICKER", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.TEXT }, + }, { propertyName: "accentColor", label: "Accent color", From 070f14b498e93a65aa805bf32825afd3f0f8ee62 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Thu, 26 Feb 2026 10:13:38 -0500 Subject: [PATCH 06/11] feat: add favorite applications (V2) (#41555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Feature Request 8479: Allow a user to favorite applications. In bigger factories there can be many workspaces and applications. Users might have access to a lot of applications, but generally only use a handful. This feature allows a user to favorite one or more applications (up to 50). Any favorited applications will show up in a virtual Favorites workspace in alphabetical order Fixes #`8479` ## Automation /ok-to-test tags="@tag.All" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 56d760c33061ec60f297e1336a324c603465ad54 > Cypress dashboard. > Tags: `@tag.All` > Spec: >
Fri, 13 Feb 2026 21:58:17 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [X] Yes - [ ] No ## Summary by CodeRabbit * **New Features** * Mark applications as favorites via a heart toggle on application cards (limit: 50) * Virtual "Favorites" workspace and dedicated view listing favorited applications * Endpoints and flows to fetch/sync favorite applications * **UX** * Favorites reflected across listings, headers, and workspace navigation (URL support) * Optimistic toggles with rollback on error and toast feedback; auto-refresh of favorites when workspaces change * **Chores** * Deleted applications are removed from users' favorites automatically --------- Co-authored-by: Cursor Co-authored-by: Claude Opus 4.6 --- app/client/src/actions/applicationActions.ts | 41 +++++ .../src/assets/icons/ads/heart-fill-red.svg | 4 + app/client/src/ce/api/ApplicationApi.tsx | 10 ++ .../src/ce/constants/ReduxActionConstants.tsx | 6 + .../src/ce/constants/workspaceConstants.ts | 10 ++ .../src/ce/pages/Applications/index.tsx | 48 +++++- .../uiReducers/applicationsReducer.tsx | 46 ++++++ .../uiReducers/selectedWorkspaceReducer.ts | 46 ++++++ app/client/src/ce/sagas/WorkspaceSagas.ts | 36 ++++- app/client/src/ce/sagas/index.tsx | 2 + .../src/ce/selectors/applicationSelectors.tsx | 28 ++++ .../selectors/selectedWorkspaceSelectors.ts | 26 ++- app/client/src/components/common/Card.tsx | 83 ++++++++-- .../editorComponents/Debugger/index.tsx | 4 +- app/client/src/entities/Application/types.ts | 1 + .../layouts/components/Header/index.tsx | 5 +- .../hooks/useWidgetSelectionBlockListener.ts | 5 +- .../pages/Applications/ApplicationCard.tsx | 25 ++- app/client/src/sagas/FavoritesSagas.ts | 131 +++++++++++++++ app/client/src/sagas/InitSagas.ts | 32 +++- .../controllers/ce/UserControllerCE.java | 26 +++ .../com/appsmith/server/domains/UserData.java | 4 + .../helpers/ce/bridge/BridgeUpdate.java | 5 + .../ce/CustomUserDataRepositoryCE.java | 38 +++++ .../ce/CustomUserDataRepositoryCEImpl.java | 44 ++++++ .../services/ApplicationPageServiceImpl.java | 6 +- .../server/services/UserDataServiceImpl.java | 3 + .../ce/ApplicationPageServiceCEImpl.java | 6 +- .../server/services/ce/UserDataServiceCE.java | 8 + .../services/ce/UserDataServiceCEImpl.java | 149 ++++++++++++++++++ 30 files changed, 848 insertions(+), 30 deletions(-) create mode 100644 app/client/src/actions/applicationActions.ts create mode 100644 app/client/src/assets/icons/ads/heart-fill-red.svg create mode 100644 app/client/src/sagas/FavoritesSagas.ts diff --git a/app/client/src/actions/applicationActions.ts b/app/client/src/actions/applicationActions.ts new file mode 100644 index 000000000000..82f62ba0cf65 --- /dev/null +++ b/app/client/src/actions/applicationActions.ts @@ -0,0 +1,41 @@ +import { + ReduxActionErrorTypes, + ReduxActionTypes, +} from "ee/constants/ReduxActionConstants"; +import type { ApplicationPayload } from "entities/Application"; + +export const toggleFavoriteApplication = (applicationId: string) => ({ + type: ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_INIT, + payload: { applicationId }, +}); + +export const toggleFavoriteApplicationSuccess = ( + applicationId: string, + isFavorited: boolean, +) => ({ + type: ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_SUCCESS, + payload: { applicationId, isFavorited }, +}); + +export const fetchFavoriteApplications = () => ({ + type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT, +}); + +export const fetchFavoriteApplicationsSuccess = ( + applications: ApplicationPayload[], +) => ({ + type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_SUCCESS, + payload: applications, +}); + +export const fetchFavoriteApplicationsError = () => ({ + type: ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR, +}); + +export const toggleFavoriteApplicationError = ( + applicationId: string, + error: unknown, +) => ({ + type: ReduxActionErrorTypes.TOGGLE_FAVORITE_APPLICATION_ERROR, + payload: { applicationId, error, show: false }, +}); diff --git a/app/client/src/assets/icons/ads/heart-fill-red.svg b/app/client/src/assets/icons/ads/heart-fill-red.svg new file mode 100644 index 000000000000..d6b0b69e8816 --- /dev/null +++ b/app/client/src/assets/icons/ads/heart-fill-red.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/client/src/ce/api/ApplicationApi.tsx b/app/client/src/ce/api/ApplicationApi.tsx index 56c1f2f5a53b..ce9dc75c31f2 100644 --- a/app/client/src/ce/api/ApplicationApi.tsx +++ b/app/client/src/ce/api/ApplicationApi.tsx @@ -564,6 +564,16 @@ export class ApplicationApi extends Api { `${ApplicationApi.baseURL}/${applicationId}/static-url/suggest-app-slug`, ); } + + static async toggleFavoriteApplication( + applicationId: string, + ): Promise> { + return Api.put(`v1/users/applications/${applicationId}/favorite`); + } + + static async getFavoriteApplications(): Promise> { + return Api.get("v1/users/favoriteApplications"); + } } export default ApplicationApi; diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index c28f390a9106..731dfdc6fa73 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -679,6 +679,10 @@ const ApplicationActionTypes = { FORK_APPLICATION_INIT: "FORK_APPLICATION_INIT", FORK_APPLICATION_SUCCESS: "FORK_APPLICATION_SUCCESS", RESET_CURRENT_APPLICATION: "RESET_CURRENT_APPLICATION", + TOGGLE_FAVORITE_APPLICATION_INIT: "TOGGLE_FAVORITE_APPLICATION_INIT", + TOGGLE_FAVORITE_APPLICATION_SUCCESS: "TOGGLE_FAVORITE_APPLICATION_SUCCESS", + FETCH_FAVORITE_APPLICATIONS_INIT: "FETCH_FAVORITE_APPLICATIONS_INIT", + FETCH_FAVORITE_APPLICATIONS_SUCCESS: "FETCH_FAVORITE_APPLICATIONS_SUCCESS", }; const ApplicationActionErrorTypes = { @@ -692,6 +696,8 @@ const ApplicationActionErrorTypes = { FETCH_APP_SLUG_SUGGESTION_ERROR: "FETCH_APP_SLUG_SUGGESTION_ERROR", ENABLE_STATIC_URL_ERROR: "ENABLE_STATIC_URL_ERROR", DISABLE_STATIC_URL_ERROR: "DISABLE_STATIC_URL_ERROR", + TOGGLE_FAVORITE_APPLICATION_ERROR: "TOGGLE_FAVORITE_APPLICATION_ERROR", + FETCH_FAVORITE_APPLICATIONS_ERROR: "FETCH_FAVORITE_APPLICATIONS_ERROR", }; const IDEDebuggerActionTypes = { diff --git a/app/client/src/ce/constants/workspaceConstants.ts b/app/client/src/ce/constants/workspaceConstants.ts index e7e8321ee11c..667fd6afa425 100644 --- a/app/client/src/ce/constants/workspaceConstants.ts +++ b/app/client/src/ce/constants/workspaceConstants.ts @@ -1,3 +1,12 @@ +export const FAVORITES_KEY = "__favorites__"; + +export const DEFAULT_FAVORITES_WORKSPACE = { + id: FAVORITES_KEY, + name: "Favorites", + isVirtual: true, + userPermissions: [] as string[], +}; + export interface WorkspaceRole { id: string; name: string; @@ -13,6 +22,7 @@ export interface Workspace { logoUrl?: string; uploadProgress?: number; userPermissions?: string[]; + isVirtual?: boolean; } export interface WorkspaceUserRoles { diff --git a/app/client/src/ce/pages/Applications/index.tsx b/app/client/src/ce/pages/Applications/index.tsx index 6ba8950a76ae..82cd5ad6fd23 100644 --- a/app/client/src/ce/pages/Applications/index.tsx +++ b/app/client/src/ce/pages/Applications/index.tsx @@ -32,13 +32,19 @@ import { getApplicationSearchKeyword, getCreateApplicationError, getCurrentApplicationIdForCreateNewApp, + getHasFavorites, getIsCreatingApplication, getIsDeletingApplication, } from "ee/selectors/applicationSelectors"; +import { + DEFAULT_FAVORITES_WORKSPACE, + FAVORITES_KEY, +} from "ee/constants/workspaceConstants"; import { Classes as BlueprintClasses } from "@blueprintjs/core"; import { Position } from "@blueprintjs/core/lib/esm/common/position"; import { leaveWorkspace } from "actions/userActions"; import NoSearchImage from "assets/images/NoSearchResult.svg"; +import HeartIconRed from "assets/icons/ads/heart-fill-red.svg"; import CenteredWrapper from "components/designSystems/appsmith/CenteredWrapper"; import { thinScrollbar, @@ -98,6 +104,7 @@ import { getApplicationsOfWorkspace, getCurrentWorkspaceId, getIsFetchingApplications, + getIsFetchingFavoriteApplications, } from "ee/selectors/selectedWorkspaceSelectors"; import { getIsFetchingMyOrganizations, @@ -331,7 +338,6 @@ export function LeftPaneSection(props: { }, dispatch, ); - dispatch(fetchAllWorkspaces()); }; return ( @@ -476,6 +482,7 @@ export function WorkspaceMenuItem({ if (!workspace.id) return null; + const isFavoritesWorkspace = workspace.id === FAVORITES_KEY; const hasLogo = workspace?.logoUrl && !imageError; const displayText = isFetchingWorkspaces ? workspace?.name @@ -483,6 +490,24 @@ export function WorkspaceMenuItem({ ? workspace.name.slice(0, 22).concat(" ...") : workspace?.name; + // Use custom component for favorites workspace with heart icon + if (isFavoritesWorkspace && !isFetchingWorkspaces) { + return ( + + + + + {displayText} + + + + ); + } + // Use custom component when there's a logo, otherwise use ListItem if (hasLogo && !isFetchingWorkspaces) { const showTooltip = workspace?.name && workspace.name.length > 22; @@ -678,6 +703,7 @@ export function ApplicationsSection(props: any) { const isSavingWorkspaceInfo = useSelector(getIsSavingWorkspaceInfo); const isFetchingWorkspaces = useSelector(getIsFetchingWorkspaces); const isFetchingApplications = useSelector(getIsFetchingApplications); + const isFetchingFavoriteApps = useSelector(getIsFetchingFavoriteApplications); const isDeletingWorkspace = useSelector(getIsDeletingWorkspace); const { isFetchingPackages } = usePackage(); const creatingApplicationMap = useSelector(getIsCreatingApplication); @@ -712,7 +738,10 @@ export function ApplicationsSection(props: any) { dispatch(updateApplication(id, data)); }; const isLoadingResources = - isFetchingWorkspaces || isFetchingApplications || isFetchingPackages; + isFetchingWorkspaces || + isFetchingApplications || + isFetchingPackages || + (activeWorkspaceId === FAVORITES_KEY && isFetchingFavoriteApps); const isGACEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled); const [ isCreateAppFromTemplateModalOpen, @@ -1121,6 +1150,7 @@ export const ApplictionsMainPage = (props: any) => { const isFetchingOrganizations = useSelector(getIsFetchingMyOrganizations); const currentOrganizationId = useSelector(activeOrganizationId); const isCloudBillingEnabled = useIsCloudBillingEnabled(); + const hasFavorites = useSelector(getHasFavorites); // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1136,6 +1166,14 @@ export const ApplictionsMainPage = (props: any) => { ) as any; } + // Inject virtual Favorites workspace at the top if user has favorites or URL is Favorites (e.g. after 404 redirect) + if ( + (hasFavorites || workspaceIdFromQueryParams === FAVORITES_KEY) && + !isFetchingWorkspaces + ) { + workspaces = [DEFAULT_FAVORITES_WORKSPACE, ...workspaces]; + } + const [activeWorkspaceId, setActiveWorkspaceId] = useState< string | undefined >( @@ -1160,10 +1198,14 @@ export const ApplictionsMainPage = (props: any) => { fetchedWorkspaceId && fetchedWorkspaceId !== activeWorkspaceId ) { - const activeWorkspace: Workspace = workspaces.find( + let activeWorkspace: Workspace | undefined = workspaces.find( (workspace: Workspace) => workspace.id === activeWorkspaceId, ); + if (!activeWorkspace && activeWorkspaceId === FAVORITES_KEY) { + activeWorkspace = DEFAULT_FAVORITES_WORKSPACE; + } + if (activeWorkspace) { dispatch({ type: ReduxActionTypes.SET_CURRENT_WORKSPACE, diff --git a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx index ee34d42f0725..48acb8f110cc 100644 --- a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx +++ b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx @@ -35,6 +35,8 @@ export const initialState: ApplicationsReduxState = { creatingApplication: {}, deletingApplication: false, forkingApplication: false, + favoriteApplicationIds: [], + isFetchingFavorites: false, importingApplication: false, importedApplication: null, isImportAppModalOpen: false, @@ -881,6 +883,48 @@ export const handlers = { isPersistingAppSlug: false, }; }, + [ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_SUCCESS]: ( + state: ApplicationsReduxState, + action: ReduxAction<{ applicationId: string; isFavorited: boolean }>, + ) => { + const { applicationId, isFavorited } = action.payload; + const matchedApp = state.applicationList.find( + (app) => (app.baseId || app.id) === applicationId, + ); + const canonicalId = matchedApp ? matchedApp.id : applicationId; + + return { + ...state, + favoriteApplicationIds: isFavorited + ? [...state.favoriteApplicationIds, canonicalId] + : state.favoriteApplicationIds.filter((id) => id !== canonicalId), + applicationList: state.applicationList.map((app) => + (app.baseId || app.id) === applicationId + ? { ...app, isFavorited } + : app, + ), + }; + }, + [ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT]: ( + state: ApplicationsReduxState, + ) => ({ + ...state, + isFetchingFavorites: true, + }), + [ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_SUCCESS]: ( + state: ApplicationsReduxState, + action: ReduxAction, + ) => ({ + ...state, + isFetchingFavorites: false, + favoriteApplicationIds: action.payload.map((app) => app.id), + }), + [ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR]: ( + state: ApplicationsReduxState, + ) => ({ + ...state, + isFetchingFavorites: false, + }), }; const applicationsReducer = createReducer(initialState, handlers); @@ -898,6 +942,8 @@ export interface ApplicationsReduxState { createApplicationError?: string; deletingApplication: boolean; forkingApplication: boolean; + favoriteApplicationIds: string[]; + isFetchingFavorites: boolean; currentApplication?: ApplicationPayload; importingApplication: boolean; importedApplication: unknown; diff --git a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts index 0d6aca3bcb3e..237826ea61b1 100644 --- a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts +++ b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts @@ -10,6 +10,7 @@ import type { WorkspaceUser, WorkspaceUserRoles, } from "ee/constants/workspaceConstants"; +import { FAVORITES_KEY } from "ee/constants/workspaceConstants"; import type { Package } from "ee/constants/PackageConstants"; import type { UpdateApplicationRequest } from "ee/api/ApplicationApi"; @@ -20,6 +21,7 @@ export interface SelectedWorkspaceReduxState { packages: Package[]; loadingStates: { isFetchingApplications: boolean; + isFetchingFavoriteApplications: boolean; isFetchingAllUsers: boolean; isFetchingCurrentWorkspace: boolean; }; @@ -35,6 +37,7 @@ export const initialState: SelectedWorkspaceReduxState = { packages: [], loadingStates: { isFetchingApplications: false, + isFetchingFavoriteApplications: false, isFetchingAllUsers: false, isFetchingCurrentWorkspace: false, }, @@ -59,6 +62,30 @@ export const handlers = { ) => { draftState.loadingStates.isFetchingApplications = false; }, + // Handle favorites workspace - populate applications with favorite apps + [ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT]: ( + draftState: SelectedWorkspaceReduxState, + ) => { + draftState.loadingStates.isFetchingFavoriteApplications = true; + }, + [ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_SUCCESS]: ( + draftState: SelectedWorkspaceReduxState, + action: ReduxAction, + ) => { + draftState.loadingStates.isFetchingFavoriteApplications = false; + + // Only replace applications when we're in the virtual favorites workspace. + // This prevents overwriting a real workspace's applications when favorites + // are fetched in the background. + if (draftState.workspace.id === FAVORITES_KEY) { + draftState.applications = action.payload; + } + }, + [ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR]: ( + draftState: SelectedWorkspaceReduxState, + ) => { + draftState.loadingStates.isFetchingFavoriteApplications = false; + }, [ReduxActionTypes.DELETE_APPLICATION_SUCCESS]: ( draftState: SelectedWorkspaceReduxState, action: ReduxAction, @@ -242,6 +269,25 @@ export const handlers = { ) => { draftState.loadingStates.isFetchingCurrentWorkspace = false; }, + [ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_SUCCESS]: ( + draftState: SelectedWorkspaceReduxState, + action: ReduxAction<{ applicationId: string; isFavorited: boolean }>, + ) => { + const { applicationId, isFavorited } = action.payload; + const isFavoritesWorkspace = draftState.workspace.id === FAVORITES_KEY; + + if (isFavoritesWorkspace && !isFavorited) { + draftState.applications = draftState.applications.filter( + (app) => (app.baseId || app.id) !== applicationId, + ); + } else { + draftState.applications = draftState.applications.map((app) => + (app.baseId || app.id) === applicationId + ? { ...app, isFavorited } + : app, + ); + } + }, }; const selectedWorkspaceReducer = createImmerReducer(initialState, handlers); diff --git a/app/client/src/ce/sagas/WorkspaceSagas.ts b/app/client/src/ce/sagas/WorkspaceSagas.ts index ffc0da5df0e6..d741c625aba0 100644 --- a/app/client/src/ce/sagas/WorkspaceSagas.ts +++ b/app/client/src/ce/sagas/WorkspaceSagas.ts @@ -30,6 +30,10 @@ import WorkspaceApi from "ee/api/WorkspaceApi"; import type { ApiResponse } from "api/ApiResponses"; import { getFetchedWorkspaces } from "ee/selectors/workspaceSelectors"; import { getCurrentUser } from "selectors/usersSelectors"; +import { + DEFAULT_FAVORITES_WORKSPACE, + FAVORITES_KEY, +} from "ee/constants/workspaceConstants"; import type { Workspace } from "ee/constants/workspaceConstants"; import history from "utils/history"; import { APPLICATIONS_URL } from "constants/routes"; @@ -63,7 +67,15 @@ export function* fetchAllWorkspacesSaga( }); if (action?.payload?.workspaceId || action?.payload?.fetchEntities) { + // When we're also fetching entities for a specific workspace (e.g. the + // Applications page), favorites will be refreshed from within + // fetchEntitiesOfWorkspaceSaga to avoid duplicate API calls. yield call(fetchEntitiesOfWorkspaceSaga, action); + } else { + // Callers that only need the workspace list (e.g. Templates, settings) + // still refresh favorites once here so the virtual Favorites workspace + // and favorite badges stay in sync. + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); } } } catch (error) { @@ -82,6 +94,18 @@ export function* fetchEntitiesOfWorkspaceSaga( try { const allWorkspaces: Workspace[] = yield select(getFetchedWorkspaces); const workspaceId = action?.payload?.workspaceId || allWorkspaces[0]?.id; + + // Handle virtual favorites workspace specially + if (workspaceId === FAVORITES_KEY) { + yield put({ + type: ReduxActionTypes.SET_CURRENT_WORKSPACE, + payload: DEFAULT_FAVORITES_WORKSPACE, + }); + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); + + return; + } + const activeWorkspace = allWorkspaces.find( (workspace) => workspace.id === workspaceId, ); @@ -95,6 +119,8 @@ export function* fetchEntitiesOfWorkspaceSaga( if (workspaceId) { yield call(failFastApiCalls, initActions, successActions, errorActions); + // Refresh favorites so the list drops any apps the user no longer has access to + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); } } catch (error) { yield put({ @@ -338,10 +364,18 @@ export function* createWorkspaceSaga( yield call(resolve); } - // get created workspace in focus + // Get created workspace in focus // @ts-expect-error: response is of type unknown const workspaceId = response.data.id; + // Refresh workspaces and entities for the newly created workspace so that + // the left panel and applications list reflect the new workspace instead of + // staying on the previous (e.g. Favorites) virtual workspace. + yield put({ + type: ReduxActionTypes.FETCH_ALL_WORKSPACES_INIT, + payload: { workspaceId, fetchEntities: true }, + }); + history.push(`${window.location.pathname}?workspaceId=${workspaceId}`); } catch (error) { yield call(reject, { _error: (error as Error).message }); diff --git a/app/client/src/ce/sagas/index.tsx b/app/client/src/ce/sagas/index.tsx index 985260fb14a9..3844fa1e985e 100644 --- a/app/client/src/ce/sagas/index.tsx +++ b/app/client/src/ce/sagas/index.tsx @@ -4,6 +4,7 @@ import SuperUserSagas from "ee/sagas/SuperUserSagas"; import organizationSagas from "ee/sagas/organizationSagas"; import userSagas from "ee/sagas/userSagas"; import workspaceSagas from "ee/sagas/WorkspaceSagas"; +import favoritesSagasListener from "sagas/FavoritesSagas"; import { watchPluginActionExecutionSagas } from "sagas/ActionExecution/PluginActionSaga"; import { watchActionSagas } from "sagas/ActionSagas"; import apiPaneSagas from "sagas/ApiPaneSagas"; @@ -115,4 +116,5 @@ export const sagas = [ gitSagas, gitApplicationSagas, PostEvaluationSagas, + favoritesSagasListener, ]; diff --git a/app/client/src/ce/selectors/applicationSelectors.tsx b/app/client/src/ce/selectors/applicationSelectors.tsx index 44052b83dc5b..4757ebd60cde 100644 --- a/app/client/src/ce/selectors/applicationSelectors.tsx +++ b/app/client/src/ce/selectors/applicationSelectors.tsx @@ -243,3 +243,31 @@ export const getRedeployApplicationTrigger = createSelector( return null; }, ); + +export const getFavoriteApplicationIds = (state: DefaultRootState) => + state.ui.applications.favoriteApplicationIds; + +export const getFavoriteApplications = createSelector( + [getApplications, getFavoriteApplicationIds], + ( + allApps: ApplicationPayload[] | undefined, + favoriteIds: string[] | undefined, + ) => { + const apps = allApps ?? []; + const ids = favoriteIds ?? []; + const favoriteIdSet = new Set(ids); + + return apps + .filter((app: ApplicationPayload) => + favoriteIdSet.has(app.baseId || app.id), + ) + .sort((a: ApplicationPayload, b: ApplicationPayload) => + a.name.localeCompare(b.name), + ); + }, +); + +export const getHasFavorites = createSelector( + [getFavoriteApplicationIds], + (favoriteIds: string[] | undefined) => (favoriteIds ?? []).length > 0, +); diff --git a/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts b/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts index 34c4f8f1469c..dd7946e43410 100644 --- a/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts +++ b/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts @@ -1,11 +1,31 @@ import type { DefaultRootState } from "react-redux"; +import { createSelector } from "reselect"; export const getIsFetchingApplications = (state: DefaultRootState): boolean => state.ui.selectedWorkspace.loadingStates.isFetchingApplications; -export const getApplicationsOfWorkspace = (state: DefaultRootState) => { - return state.ui.selectedWorkspace.applications; -}; +export const getIsFetchingFavoriteApplications = ( + state: DefaultRootState, +): boolean => + state.ui.selectedWorkspace.loadingStates.isFetchingFavoriteApplications; + +const selectWorkspaceApplications = (state: DefaultRootState) => + state.ui.selectedWorkspace.applications; + +const selectFavoriteApplicationIds = (state: DefaultRootState) => + state.ui.applications.favoriteApplicationIds || []; + +export const getApplicationsOfWorkspace = createSelector( + [selectWorkspaceApplications, selectFavoriteApplicationIds], + (applications, favoriteApplicationIds) => + // Compute isFavorited for each application based on favoriteApplicationIds. + // This ensures favorites persist when switching between workspaces while + // avoiding unnecessary re-renders when inputs haven't changed. + applications.map((app) => ({ + ...app, + isFavorited: favoriteApplicationIds.includes(app.id), + })), +); export const getAllUsersOfWorkspace = (state: DefaultRootState) => state.ui.selectedWorkspace.users; diff --git a/app/client/src/components/common/Card.tsx b/app/client/src/components/common/Card.tsx index c3fa28779727..d96d45ab51a0 100644 --- a/app/client/src/components/common/Card.tsx +++ b/app/client/src/components/common/Card.tsx @@ -1,11 +1,11 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import styled from "styled-components"; import { Card as BlueprintCard, Classes } from "@blueprintjs/core"; import { omit } from "lodash"; import { AppIcon, Size, TextType, Text } from "@appsmith/ads-old"; import type { PropsWithChildren } from "react"; import type { HTMLDivProps, ICardProps } from "@blueprintjs/core"; -import { Button, type MenuItemProps } from "@appsmith/ads"; +import { Button, Icon, type MenuItemProps } from "@appsmith/ads"; import GitConnectedBadge from "./GitConnectedBadge"; import { GitCardBadge } from "git"; @@ -32,6 +32,8 @@ type CardProps = PropsWithChildren<{ titleTestId: string; isSelected?: boolean; hasEditPermission?: boolean; + isFavorited?: boolean; + onToggleFavorite?: (e: React.MouseEvent) => void; }>; interface NameWrapperProps { @@ -105,6 +107,43 @@ const CircleAppIcon = styled(AppIcon)` } `; +const FavoriteIconWrapper = styled.button` + position: absolute; + top: 8px; + left: 8px; + z-index: 2; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + /* Slightly smaller footprint so the favorite icon feels less crowded + next to long application names. */ + width: 20px; + height: 20px; + background-color: rgba(255, 255, 255, 0.9); + border-radius: 50%; + transition: all 0.2s ease; + + /* Reset default button styles */ + margin: 0; + padding: 0; + border: none; + font: inherit; + color: inherit; + appearance: none; + -webkit-appearance: none; + + &:hover { + background-color: rgba(255, 255, 255, 1); + transform: scale(1.1); + } + + &:focus-visible { + outline: 2px solid var(--ads-v2-color-border-emphasis); + outline-offset: 2px; + } +`; + const NameWrapper = styled((props: HTMLDivProps & NameWrapperProps) => (
; }, [isGitModEnabled]); + const handleMouseLeave = useCallback(() => { + // If the menu is not open, then setOverlay false + // Set overlay false on outside click. + !isContextMenuOpen && setShowOverlay(false); + }, [isContextMenuOpen, setShowOverlay]); + + const handleMouseOver = useCallback(() => { + !isFetching && setShowOverlay(true); + }, [isFetching, setShowOverlay]); + + const handleFavoriteClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onToggleFavorite?.(e); + }, + [onToggleFavorite], + ); + return ( { - // If the menu is not open, then setOverlay false - // Set overlay false on outside click. - !isContextMenuOpen && setShowOverlay(false); - }} - onMouseOver={() => { - !isFetching && setShowOverlay(true); - }} + onMouseLeave={handleMouseLeave} + onMouseOver={handleMouseOver} showOverlay={showOverlay} testId={testId} > @@ -365,6 +418,16 @@ function Card({ hasReadPermission={hasReadPermission} isMobile={isMobile} > + {onToggleFavorite && ( + + + + )} {/*@ts-expect-error fix this the next time the file is edited*/} { + // Sync the global error count with debugger message counters. + // Only depends on the current error count so we don't dispatch on every render. dispatch(setErrorCount(messageCounters.errors)); - }); + }, [dispatch, messageCounters.errors]); const onClick = useDebuggerTriggerClick(); diff --git a/app/client/src/entities/Application/types.ts b/app/client/src/entities/Application/types.ts index d88e1029e026..d9460656c9db 100644 --- a/app/client/src/entities/Application/types.ts +++ b/app/client/src/entities/Application/types.ts @@ -46,6 +46,7 @@ export interface ApplicationPayload { publishedAppToCommunityTemplate?: boolean; forkedFromTemplateTitle?: string; connectedWorkflowId?: string; + isFavorited?: boolean; staticUrlSettings?: { enabled: boolean; uniqueSlug: string; diff --git a/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx b/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx index 9b2c64ec1a24..349b98e5bcab 100644 --- a/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx +++ b/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx @@ -162,7 +162,10 @@ const Header = () => { {currentWorkspace.name && ( <> - + {currentWorkspace.name} {"/"} diff --git a/app/client/src/pages/AppIDE/layouts/hooks/useWidgetSelectionBlockListener.ts b/app/client/src/pages/AppIDE/layouts/hooks/useWidgetSelectionBlockListener.ts index 72eed0de9de6..396dbd36d3c2 100644 --- a/app/client/src/pages/AppIDE/layouts/hooks/useWidgetSelectionBlockListener.ts +++ b/app/client/src/pages/AppIDE/layouts/hooks/useWidgetSelectionBlockListener.ts @@ -19,8 +19,11 @@ export function useWidgetSelectionBlockListener() { FocusEntity.WIDGET_LIST, ].includes(currentFocus.entity); + // Block or unblock widget selection based only on the focused entity type. + // We depend on `currentFocus.entity` instead of the full object to avoid + // re-dispatching on every render with a new object reference. dispatch(setWidgetSelectionBlock(!inUIMode)); - }, [currentFocus, dispatch]); + }, [currentFocus.entity, dispatch]); useEffect(() => { window.addEventListener("keydown", handleKeyDown); diff --git a/app/client/src/pages/Applications/ApplicationCard.tsx b/app/client/src/pages/Applications/ApplicationCard.tsx index 4e0bcd81df08..fcf59b3952b4 100644 --- a/app/client/src/pages/Applications/ApplicationCard.tsx +++ b/app/client/src/pages/Applications/ApplicationCard.tsx @@ -50,6 +50,7 @@ import history from "utils/history"; import urlBuilder from "ee/entities/URLRedirect/URLAssembly"; import { toast } from "@appsmith/ads"; import { getCurrentUser } from "actions/authActions"; +import { toggleFavoriteApplication } from "actions/applicationActions"; import Card, { ContextMenuTrigger } from "components/common/Card"; import { generateEditedByText } from "./helpers"; import { noop } from "lodash"; @@ -210,21 +211,23 @@ export function ApplicationCard(props: ApplicationCardProps) { const appIcon = (application.icon || getApplicationIcon(applicationId)) as AppIconName; + + // Permissions are enriched upstream (e.g. in FavoritesSagas); no local lookup needed. + const userPermissions = application.userPermissions ?? []; + const hasEditPermission = isPermitted( - application.userPermissions ?? [], + userPermissions, PERMISSION_TYPE.MANAGE_APPLICATION, ); const hasReadPermission = isPermitted( - application.userPermissions ?? [], + userPermissions, PERMISSION_TYPE.READ_APPLICATION, ); const hasExportPermission = isPermitted( - application.userPermissions ?? [], + userPermissions, PERMISSION_TYPE.EXPORT_APPLICATION, ); - const hasDeletePermission = hasDeleteApplicationPermission( - application.userPermissions, - ); + const hasDeletePermission = hasDeleteApplicationPermission(userPermissions); const updateColor = (color: string) => { props.update && @@ -521,6 +524,14 @@ export function ApplicationCard(props: ApplicationCardProps) { dispatch(getCurrentUser()); }, [setURLParams, viewModeURL, dispatch]); + const handleToggleFavorite = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + dispatch(toggleFavoriteApplication(application.baseId || application.id)); + }, + [application.baseId, application.id, dispatch], + ); + return ( , +) { + const { applicationId } = action.payload; + let isFavorited: boolean = false; + + try { + const currentFavoriteIds: string[] = yield select( + (state) => state.ui.applications.favoriteApplicationIds, + ); + + isFavorited = currentFavoriteIds.includes(applicationId); + + if ( + !isFavorited && + currentFavoriteIds.length >= MAX_FAVORITE_APPLICATIONS_LIMIT + ) { + toast.show( + `Maximum favorite applications limit (${MAX_FAVORITE_APPLICATIONS_LIMIT}) reached`, + { kind: "error" }, + ); + + return; + } + + const newIsFavorited = !isFavorited; + + yield put(toggleFavoriteApplicationSuccess(applicationId, newIsFavorited)); + + const response: ApiResponse = yield call( + ApplicationApi.toggleFavoriteApplication, + applicationId, + ); + const isValidResponse: boolean = yield validateResponse(response, false); + + if (!isValidResponse) { + yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); + + return; + } + } catch (error: unknown) { + yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); + + yield put(toggleFavoriteApplicationError(applicationId, error)); + + const message = + error instanceof Error + ? error.message + : "Failed to update favorite status"; + + toast.show(message, { kind: "error" }); + } +} + +function* fetchFavoriteApplicationsSaga() { + try { + const response: ApiResponse = yield call( + ApplicationApi.getFavoriteApplications, + ); + const isValidResponse: boolean = yield validateResponse(response); + + if (isValidResponse) { + const rawApplications = response.data; + + // Build a permissions lookup from the main application list so favorite + // apps returned by the API (which may omit permissions) are enriched. + const allApplications: ApplicationPayload[] = + (yield select(getApplications)) ?? []; + const permissionsById = new Map(); + + for (const app of allApplications) { + if (app.userPermissions?.length) { + permissionsById.set(app.id, app.userPermissions); + } + } + + const applications = rawApplications.map( + (application: ApplicationPayload) => { + const defaultPage = findDefaultPage(application.pages); + const userPermissions = application.userPermissions?.length + ? application.userPermissions + : permissionsById.get(application.id) ?? []; + + return { + ...application, + defaultPageId: defaultPage?.id, + defaultBasePageId: defaultPage?.baseId, + userPermissions, + }; + }, + ); + + yield put(fetchFavoriteApplicationsSuccess(applications)); + } else { + yield put(fetchFavoriteApplicationsError()); + } + } catch (error) { + yield put(fetchFavoriteApplicationsError()); + } +} + +export default function* favoritesSagasListener() { + yield takeLeading( + ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_INIT, + toggleFavoriteApplicationSaga, + ); + yield takeLatest( + ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT, + fetchFavoriteApplicationsSaga, + ); +} diff --git a/app/client/src/sagas/InitSagas.ts b/app/client/src/sagas/InitSagas.ts index 6188f2ae23cf..279f0e261223 100644 --- a/app/client/src/sagas/InitSagas.ts +++ b/app/client/src/sagas/InitSagas.ts @@ -61,10 +61,19 @@ import { import { APP_MODE } from "../entities/App"; import { GIT_BRANCH_QUERY_KEY } from "../constants/routes"; import AnalyticsUtil from "ee/utils/AnalyticsUtil"; -import { getAppMode } from "ee/selectors/applicationSelectors"; +import { + getAppMode, + getFavoriteApplicationIds, +} from "ee/selectors/applicationSelectors"; import { getDebuggerErrors } from "selectors/debuggerSelectors"; import { deleteErrorLog } from "actions/debuggerActions"; import { getCurrentUser } from "actions/authActions"; +import { getCurrentUser as getCurrentUserSelector } from "selectors/usersSelectors"; +import { ANONYMOUS_USERNAME } from "constants/userConstants"; +import history from "utils/history"; +import { APPLICATIONS_URL } from "constants/routes"; +import { FAVORITES_KEY } from "ee/constants/workspaceConstants"; +import { toast } from "@appsmith/ads"; import { getCurrentOrganization } from "ee/actions/organizationActions"; import { @@ -416,6 +425,27 @@ export function* startAppEngine(action: ReduxAction) { if (e instanceof AppEngineApiError) return; + if (e instanceof PageNotFoundError) { + const currentUser: ReturnType = + yield select(getCurrentUserSelector); + + if (currentUser && currentUser.email !== ANONYMOUS_USERNAME) { + // Only redirect to favorites page if the app was actually favorited; + // otherwise fall through to safeCrashAppRequest to show the error page. + const favoriteIds: string[] = yield select(getFavoriteApplicationIds); + + if (favoriteIds.includes(action.payload.applicationId ?? "")) { + history.replace(`${APPLICATIONS_URL}?workspaceId=${FAVORITES_KEY}`); + yield put({ + type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT, + }); + toast.show("Application not found or deleted.", { kind: "error" }); + + return; + } + } + } + appsmithTelemetry.captureException(e, { errorName: "AppEngineError" }); yield put(safeCrashAppRequest()); } finally { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java index faed60b418e3..df45b419343e 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java @@ -2,6 +2,7 @@ import com.appsmith.external.views.Views; import com.appsmith.server.constants.Url; +import com.appsmith.server.domains.Application; import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserData; import com.appsmith.server.dtos.InviteUsersDTO; @@ -206,4 +207,29 @@ public Mono> resendEmailVerification( public Mono verifyEmailVerificationToken(ServerWebExchange exchange) { return service.verifyEmailVerificationToken(exchange); } + + /** + * Toggle favorite status for an application + * @param applicationId Application ID to toggle favorite status for + * @return Updated user data with modified favorites list + */ + @JsonView(Views.Public.class) + @PutMapping("/applications/{applicationId}/favorite") + public Mono> toggleFavoriteApplication(@PathVariable String applicationId) { + return userDataService + .toggleFavoriteApplication(applicationId) + .map(userData -> new ResponseDTO<>(HttpStatus.OK, userData)); + } + + /** + * Get all favorite applications for the current user + * @return List of favorited applications + */ + @JsonView(Views.Public.class) + @GetMapping("/favoriteApplications") + public Mono>> getFavoriteApplications() { + return userDataService + .getFavoriteApplications() + .map(applications -> new ResponseDTO<>(HttpStatus.OK, applications)); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java index 6daca7445769..1ffc658cd479 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java @@ -63,6 +63,10 @@ public class UserData extends BaseDomain { @JsonView(Views.Public.class) private List recentlyUsedEntityIds; + // List of application IDs favorited by the user + @JsonView(Views.Public.class) + private List favoriteApplicationIds; + // Map of defaultApplicationIds with the GitProfiles. For fallback/default git profile per user default will be the // the key for the map @JsonView(Views.Internal.class) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java index 302447b976b5..2927f0cc6f36 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java @@ -22,6 +22,11 @@ public BridgeUpdate push(@NonNull String key, @NonNull Object value) { return this; } + public BridgeUpdate addToSet(@NonNull String key, @NonNull Object value) { + update.addToSet(key, value); + return this; + } + public BridgeUpdate pull(@NonNull String key, @NonNull Object value) { update.pull(key, value); return this; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java index 7dea190b87f9..3cc8db080c10 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java @@ -11,4 +11,42 @@ public interface CustomUserDataRepositoryCE extends AppsmithRepository Mono removeEntitiesFromRecentlyUsedList(String userId, String workspaceId); Mono fetchMostRecentlyUsedWorkspaceId(String userId); + + Mono removeApplicationFromFavorites(String applicationId); + + /** + * Add an application to a single user's favorites list using an atomic update. + * + * @param userId ID of the user whose favorites list should be updated + * @param applicationId ID of the application to add to favorites + * @return Completion signal when the update operation finishes + */ + Mono addFavoriteApplicationForUser(String userId, String applicationId); + + /** + * Atomically add an application to a user's favorites list only if the list + * has fewer than {@code maxLimit} entries. Uses a single conditional MongoDB + * update ({@code $addToSet} + array-index existence check) so the limit + * cannot be exceeded by concurrent requests. + * + * @param userId ID of the user whose favorites list should be updated + * @param applicationId ID of the application to add to favorites + * @param maxLimit Maximum allowed size of the favorites list + * @return Number of matched documents: 1 if the update was applied, + * 0 if the array already had {@code maxLimit} or more entries + */ + Mono addFavoriteApplicationForUserIfUnderLimit(String userId, String applicationId, int maxLimit); + + /** + * Atomically remove an application from a user's favorites list only if it + * is present. The query matches the user document only when the array + * contains {@code applicationId}, so the returned count doubles as a + * "was-it-actually-removed?" signal. + * + * @param userId ID of the user whose favorites list should be updated + * @param applicationId ID of the application to remove from favorites + * @return Number of matched documents: 1 if the application was removed, + * 0 if it was not in the favorites list + */ + Mono removeFavoriteApplicationForUser(String userId, String applicationId); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java index 1feb33de0a6b..a439e8dc94c2 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java @@ -6,6 +6,7 @@ import com.appsmith.server.helpers.ce.bridge.BridgeUpdate; import com.appsmith.server.projections.UserRecentlyUsedEntitiesProjection; import com.appsmith.server.repositories.BaseAppsmithRepositoryImpl; +import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Mono; @@ -45,4 +46,47 @@ public Mono fetchMostRecentlyUsedWorkspaceId(String userId) { : recentlyUsedWorkspaceIds.get(0).getWorkspaceId(); }); } + + @Override + public Mono removeApplicationFromFavorites(String applicationId) { + // MongoDB update query to pull applicationId from all users' favoriteApplicationIds arrays + BridgeUpdate update = new BridgeUpdate(); + update.pull(UserData.Fields.favoriteApplicationIds, applicationId); + return queryBuilder().updateAll(update).then(); + } + + @Override + public Mono addFavoriteApplicationForUser(String userId, String applicationId) { + BridgeUpdate update = new BridgeUpdate(); + update.addToSet(UserData.Fields.favoriteApplicationIds, applicationId); + return queryBuilder() + .criteria(Bridge.equal(UserData.Fields.userId, userId)) + .updateFirst(update) + .then(); + } + + @Override + public Mono addFavoriteApplicationForUserIfUnderLimit(String userId, String applicationId, int maxLimit) { + BridgeUpdate update = new BridgeUpdate(); + update.addToSet(UserData.Fields.favoriteApplicationIds, applicationId); + // Array-index existence trick: "field.{N-1}" not existing means the array has fewer than N elements. + Criteria criteria = Criteria.where(UserData.Fields.userId) + .is(userId) + .and(UserData.Fields.favoriteApplicationIds + "." + (maxLimit - 1)) + .exists(false); + return queryBuilder().criteria(criteria).updateFirst(update); + } + + @Override + public Mono removeFavoriteApplicationForUser(String userId, String applicationId) { + BridgeUpdate update = new BridgeUpdate(); + update.pull(UserData.Fields.favoriteApplicationIds, applicationId); + // Only match if the array actually contains the applicationId so that + // matchedCount == 1 means "removed" and 0 means "was not present". + Criteria criteria = Criteria.where(UserData.Fields.userId) + .is(userId) + .and(UserData.Fields.favoriteApplicationIds) + .is(applicationId); + return queryBuilder().criteria(criteria).updateFirst(update); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java index f8d3e95e18e1..2a8032868088 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java @@ -67,7 +67,8 @@ public ApplicationPageServiceImpl( ClonePageService actionCollectionClonePageService, ObservationRegistry observationRegistry, CacheableRepositoryHelper cacheableRepositoryHelper, - PostPublishHookCoordinatorService postApplicationPublishHookCoordinatorService) { + PostPublishHookCoordinatorService postApplicationPublishHookCoordinatorService, + UserDataService userDataService) { super( workspaceService, applicationService, @@ -99,6 +100,7 @@ public ApplicationPageServiceImpl( actionCollectionClonePageService, observationRegistry, cacheableRepositoryHelper, - postApplicationPublishHookCoordinatorService); + postApplicationPublishHookCoordinatorService, + userDataService); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java index 030c6cc69f29..7d20d987d920 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java @@ -4,6 +4,7 @@ import com.appsmith.server.repositories.UserDataRepository; import com.appsmith.server.repositories.UserRepository; import com.appsmith.server.services.ce.UserDataServiceCEImpl; +import com.appsmith.server.solutions.ApplicationPermission; import com.appsmith.server.solutions.ReleaseNotesService; import jakarta.validation.Validator; import org.springframework.stereotype.Service; @@ -21,6 +22,7 @@ public UserDataServiceImpl( ReleaseNotesService releaseNotesService, FeatureFlagService featureFlagService, ApplicationRepository applicationRepository, + ApplicationPermission applicationPermission, OrganizationService organizationService) { super( @@ -33,6 +35,7 @@ public UserDataServiceImpl( releaseNotesService, featureFlagService, applicationRepository, + applicationPermission, organizationService); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java index 3d9485e7cb90..b8d7452d9bb7 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java @@ -53,6 +53,7 @@ import com.appsmith.server.services.AnalyticsService; import com.appsmith.server.services.PermissionGroupService; import com.appsmith.server.services.SessionUserService; +import com.appsmith.server.services.UserDataService; import com.appsmith.server.services.WorkspaceService; import com.appsmith.server.solutions.ActionPermission; import com.appsmith.server.solutions.ApplicationPermission; @@ -140,6 +141,7 @@ public class ApplicationPageServiceCEImpl implements ApplicationPageServiceCE { private final CacheableRepositoryHelper cacheableRepositoryHelper; private final PostPublishHookCoordinatorService postApplicationPublishHookCoordinatorService; + private final UserDataService userDataService; @Override public Mono createPage(PageDTO page) { @@ -572,6 +574,7 @@ protected Mono deleteApplicationResources(Application application) Mono actionPermissionMono = actionPermission.getDeletePermission().cache(); Mono pagePermissionMono = pagePermission.getDeletePermission(); + String favoriteId = application.getBaseId(); return actionPermissionMono .flatMap(actionDeletePermission -> actionCollectionService.archiveActionCollectionByApplicationId( application.getId(), actionDeletePermission)) @@ -580,7 +583,8 @@ protected Mono deleteApplicationResources(Application application) .then(pagePermissionMono.flatMap(pageDeletePermission -> newPageService.archivePagesByApplicationId(application.getId(), pageDeletePermission))) .then(themeService.archiveApplicationThemes(application)) - .flatMap(applicationService::archive); + .then(userDataService.removeApplicationFromAllFavorites(favoriteId)) + .then(applicationService.archive(application)); } protected Mono sendAppDeleteAnalytics(Application deletedApplication) { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java index 0acc7e55537c..a46770443b08 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java @@ -1,6 +1,7 @@ package com.appsmith.server.services.ce; import com.appsmith.external.enums.WorkspaceResourceContext; +import com.appsmith.server.domains.Application; import com.appsmith.server.domains.GitProfile; import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserData; @@ -9,6 +10,7 @@ import reactor.core.publisher.Mono; import java.util.Collection; +import java.util.List; import java.util.Map; public interface UserDataServiceCE { @@ -51,4 +53,10 @@ Mono updateLastUsedResourceAndWorkspaceList( Mono removeRecentWorkspaceAndChildEntities(String userId, String workspaceId); Mono getGitProfileForCurrentUser(String defaultApplicationId); + + Mono toggleFavoriteApplication(String applicationId); + + Mono> getFavoriteApplications(); + + Mono removeApplicationFromAllFavorites(String applicationId); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java index ffc30386d891..1fe5cc1b6d77 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java @@ -1,7 +1,9 @@ package com.appsmith.server.services.ce; import com.appsmith.external.enums.WorkspaceResourceContext; +import com.appsmith.server.acl.AclPermission; import com.appsmith.server.constants.FieldName; +import com.appsmith.server.domains.Application; import com.appsmith.server.domains.Asset; import com.appsmith.server.domains.GitProfile; import com.appsmith.server.domains.User; @@ -22,10 +24,12 @@ import com.appsmith.server.services.FeatureFlagService; import com.appsmith.server.services.OrganizationService; import com.appsmith.server.services.SessionUserService; +import com.appsmith.server.solutions.ApplicationPermission; import com.appsmith.server.solutions.ReleaseNotesService; import jakarta.validation.Validator; import org.apache.commons.lang3.ObjectUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; import org.springframework.http.codec.multipart.Part; import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; @@ -34,6 +38,7 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -54,6 +59,8 @@ public class UserDataServiceCEImpl extends BaseService getGitProfileForCurrentUser(String defaultApplicationId) return authorProfile; }); } + + /** + * Toggle favorite status for an application + * @param applicationId Application ID to toggle + * @return Updated UserData with modified favorites list + * @throws AppsmithException if the maximum favorite limit is reached when trying to add a favorite + */ + @Override + public Mono toggleFavoriteApplication(String applicationId) { + return sessionUserService.getCurrentUser().zipWhen(this::getForUser).flatMap(tuple -> { + User user = tuple.getT1(); + UserData userData = tuple.getT2(); + + // For new users without a persisted UserData document the atomic + // repo operations will not match anything, so fall back to save. + // If a concurrent request creates the document first (DuplicateKeyException), + // retry through the atomic existing-user path. + if (userData.getId() == null) { + List favorites = userData.getFavoriteApplicationIds(); + if (favorites == null) { + favorites = new ArrayList<>(); + } + + if (favorites.remove(applicationId)) { + userData.setFavoriteApplicationIds(favorites); + return repository.save(userData).onErrorResume(DuplicateKeyException.class, ex -> repository + .removeFavoriteApplicationForUser(user.getId(), applicationId) + .flatMap(count -> getForUser(user.getId()))); + } + + // Adding — verify access first + AclPermission readPermission = applicationPermission.getReadPermission(); + List finalFavorites = favorites; + return applicationRepository + .queryBuilder() + .criteria(Bridge.equal(Application.Fields.id, applicationId)) + .permission(readPermission) + .first() + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId))) + .flatMap(application -> { + if (finalFavorites.size() >= MAX_FAVORITE_APPLICATIONS_LIMIT) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + String.format( + "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", + MAX_FAVORITE_APPLICATIONS_LIMIT))); + } + finalFavorites.add(applicationId); + userData.setFavoriteApplicationIds(finalFavorites); + return repository.save(userData).onErrorResume(DuplicateKeyException.class, ex -> repository + .addFavoriteApplicationForUserIfUnderLimit( + user.getId(), applicationId, MAX_FAVORITE_APPLICATIONS_LIMIT) + .flatMap(matchedCount -> { + if (matchedCount == 0) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + String.format( + "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", + MAX_FAVORITE_APPLICATIONS_LIMIT))); + } + return getForUser(user.getId()); + })); + }); + } + + // For existing users, let the DB decide: try an atomic remove first. + return repository + .removeFavoriteApplicationForUser(user.getId(), applicationId) + .flatMap(removedCount -> { + if (removedCount > 0) { + // Was in favorites and has been removed. + return getForUser(user.getId()); + } + + // Not in favorites — add it after verifying access. + AclPermission readPermission = applicationPermission.getReadPermission(); + return applicationRepository + .queryBuilder() + .criteria(Bridge.equal(Application.Fields.id, applicationId)) + .permission(readPermission) + .first() + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId))) + .flatMap(application -> repository + .addFavoriteApplicationForUserIfUnderLimit( + user.getId(), applicationId, MAX_FAVORITE_APPLICATIONS_LIMIT) + .flatMap(matchedCount -> { + if (matchedCount == 0) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + String.format( + "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", + MAX_FAVORITE_APPLICATIONS_LIMIT))); + } + return getForUser(user.getId()); + })); + }); + }); + } + + /** + * Get all favorite applications for current user + * Filters out deleted applications and applications user no longer has access to + * @return List of favorite applications + */ + @Override + public Mono> getFavoriteApplications() { + return getForCurrentUser().flatMap(userData -> { + List favoriteIds = userData.getFavoriteApplicationIds(); + if (CollectionUtils.isNullOrEmpty(favoriteIds)) { + return Mono.just(Collections.emptyList()); + } + + AclPermission readPermission = applicationPermission.getReadPermission(); + return applicationRepository + .queryBuilder() + .criteria(Bridge.in(Application.Fields.id, favoriteIds)) + .permission(readPermission) + .all() + .collectList(); + }); + } + + /** + * Remove application from all users' favorites when app is deleted + * @param applicationId ID of deleted application + */ + @Override + public Mono removeApplicationFromAllFavorites(String applicationId) { + return repository.removeApplicationFromFavorites(applicationId); + } } From f13026c00d94b22a3626dec538a74d25539e1181 Mon Sep 17 00:00:00 2001 From: Luis Ibarra <98054342+sebastianiv21@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:42:54 -0500 Subject: [PATCH 07/11] =?UTF-8?q?feat(client):=20add=20BetterBugs=20record?= =?UTF-8?q?ing=20links=20with=20airgap=20and=20disable=20=E2=80=A6=20(#415?= =?UTF-8?q?76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description > [!TIP] > _Add a TL;DR when the description is longer than 500 words or extremely technical (helps the content, marketing, and DevRel team)._ > > _Please also include relevant motivation and context. List any dependencies that are required for this change. Add links to Notion, Figma or any other documents that might be relevant to the PR._ …support - Inject logs-capture.js and recorder.js in index.html when BetterBugs is enabled and not airgapped; respect APPSMITH_DISABLE_BETTERBUGS and APPSMITH_AIRGAP_ENABLED - Set __BetterbugsRecordingLinkConfig with default primaryColor #E15615 and success copy aligned with in-app widget - Declare DISABLE_BETTERBUGS once in head and remove duplicate in body to fix redeclaration error - Add JSDoc in betterbugs.ts noting recording-link scripts are loaded in index.html Fixes #`Issue Number` _or_ Fixes `Issue URL` > [!WARNING] > _If no issue exists, please create an issue first, and check with the maintainers if the issue is valid._ ## Automation /ok-to-test tags="@tag.All" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 47e9bc52b12118ae6f004fd324c6cf13b71032cc > Cypress dashboard. > Tags: `@tag.All` > Spec: >
Wed, 25 Feb 2026 22:45:26 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit * **Chores** * Updated BetterBugs recording script configuration to support conditional loading based on environment settings, improving configuration management and eliminating duplicate variable declarations. * **Documentation** * Added comprehensive documentation describing BetterBugs in-app recording functionality, including details on script loading behavior and when features activate. --- app/client/public/index.html | 24 +++++++++++++++++++- app/client/src/utils/Analytics/betterbugs.ts | 4 ++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/client/public/index.html b/app/client/public/index.html index 52352ef74465..101041e32833 100755 --- a/app/client/public/index.html +++ b/app/client/public/index.html @@ -41,6 +41,7 @@ const CLOUD_HOSTING = parseConfig('{{env "APPSMITH_CLOUD_HOSTING"}}'); const AIRGAPPED = parseConfig('{{env "APPSMITH_AIRGAP_ENABLED"}}'); const REO_CLIENT_ID = parseConfig('{{env "APPSMITH_REO_CLIENT_ID"}}'); + const DISABLE_BETTERBUGS = parseConfig('{{env "APPSMITH_DISABLE_BETTERBUGS"}}'); + + + + @@ -160,7 +183,6 @@ parseConfig("%REACT_APP_INTERCOM_APP_ID%") || parseConfig('{{env "APPSMITH_INTERCOM_APP_ID"}}'); const DISABLE_INTERCOM = parseConfig('{{env "APPSMITH_DISABLE_INTERCOM"}}'); - const DISABLE_BETTERBUGS = parseConfig('{{env "APPSMITH_DISABLE_BETTERBUGS"}}'); // Initialize the Intercom library if (INTERCOM_APP_ID.length && !DISABLE_INTERCOM) { diff --git a/app/client/src/utils/Analytics/betterbugs.ts b/app/client/src/utils/Analytics/betterbugs.ts index 04ee427b6533..bc3b8a036bf8 100644 --- a/app/client/src/utils/Analytics/betterbugs.ts +++ b/app/client/src/utils/Analytics/betterbugs.ts @@ -8,6 +8,10 @@ import log from "loglevel"; import { APPSMITH_BRAND_PRIMARY_COLOR } from "utils/BrandingUtils"; import { isAirgapped } from "ee/utils/airgapHelpers"; +/** + * BetterBugs in-app widget (init/show/hide below). Recording-link scripts (logs-capture.js, recorder.js) + * are loaded in index.html when BetterBugs is enabled and not airgapped; they activate when users open a recording URL. + */ export interface BetterbugsMetadata { instanceId: string; tenantId: string | undefined; From bf3016a501d00777d211dcd9d1cdeb0ce7cb9fb0 Mon Sep 17 00:00:00 2001 From: Wyatt Walter Date: Thu, 26 Feb 2026 13:52:03 -0600 Subject: [PATCH 08/11] feat: enable on-the-fly response compression in Caddy (#41577) Add `encode zstd gzip` to the Caddy configuration so that responses are compressed based on the client's Accept-Encoding header. This ensures customers get compressed API and static responses even when their ingress controller or load balancer doesn't handle compression (e.g., layer 4 proxies). ## Description > [!TIP] > _Add a TL;DR when the description is longer than 500 words or extremely technical (helps the content, marketing, and DevRel team)._ > > _Please also include relevant motivation and context. List any dependencies that are required for this change. Add links to Notion, Figma or any other documents that might be relevant to the PR._ Fixes #`Issue Number` _or_ Fixes `Issue URL` > [!WARNING] > _If no issue exists, please create an issue first, and check with the maintainers if the issue is valid._ ## Automation /ok-to-test tags="" ### :mag: Cypress test results > [!CAUTION] > If you modify the content in this section, you are likely to disrupt the CI result for your PR. ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit * **New Features** * Enabled HTTP response compression to automatically reduce content sizes for improved performance and faster downloads. * **Tests** * Added test coverage to verify on-the-fly compression behavior across different encoding types and client scenarios. Co-authored-by: Claude Opus 4.6 --- .../fs/opt/appsmith/caddy-reconfigure.mjs | 3 +++ .../docker/route-tests/common/encoding.hurl | 21 +++++++++++++++++++ deploy/docker/route-tests/entrypoint.sh | 3 +++ 3 files changed, 27 insertions(+) create mode 100644 deploy/docker/route-tests/common/encoding.hurl diff --git a/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs b/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs index 534cdc7ef621..4f5c94134adc 100644 --- a/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs +++ b/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs @@ -84,6 +84,9 @@ parts.push(` } log_skip @source-map-files + # On-the-fly compression, honoring the client's Accept-Encoding header. + encode zstd gzip + # The internal request ID header should never be accepted from an incoming request. request_header -X-Appsmith-Request-Id diff --git a/deploy/docker/route-tests/common/encoding.hurl b/deploy/docker/route-tests/common/encoding.hurl new file mode 100644 index 000000000000..65f69043b4f5 --- /dev/null +++ b/deploy/docker/route-tests/common/encoding.hurl @@ -0,0 +1,21 @@ +# Verify on-the-fly compression when the client requests it. + +# gzip encoding +GET http://localhost/static/test-encoding.txt +Accept-Encoding: gzip +HTTP 200 +[Asserts] +header "Content-Encoding" == "gzip" + +# zstd encoding +GET http://localhost/static/test-encoding.txt +Accept-Encoding: zstd +HTTP 200 +[Asserts] +header "Content-Encoding" == "zstd" + +# No encoding requested, no encoding applied +GET http://localhost/static/test-encoding.txt +HTTP 200 +[Asserts] +header "Content-Encoding" not exists diff --git a/deploy/docker/route-tests/entrypoint.sh b/deploy/docker/route-tests/entrypoint.sh index 8e0d98c264fc..2e2a5b45165a 100644 --- a/deploy/docker/route-tests/entrypoint.sh +++ b/deploy/docker/route-tests/entrypoint.sh @@ -49,6 +49,9 @@ mkdir -p "$WWW_PATH" /opt/appsmith/editor echo -n 'index.html body, this will be replaced' > "$WWW_PATH/index.html" echo '{}' > /opt/appsmith/info.json echo -n 'actual index.html body' > /opt/appsmith/editor/index.html +# A file large enough (>256 bytes) for Caddy's encode directive to compress. +mkdir -p /opt/appsmith/editor/static +printf 'a%.0s' {1..512} > /opt/appsmith/editor/static/test-encoding.txt mkcert -install # Start echo server From f16eacfa887397e397f65fb667c88136ad58ba37 Mon Sep 17 00:00:00 2001 From: Wyatt Walter Date: Thu, 26 Feb 2026 14:49:48 -0600 Subject: [PATCH 09/11] chore: address pinned old jdk version (#41580) ## Description > [!TIP] > _Add a TL;DR when the description is longer than 500 words or extremely technical (helps the content, marketing, and DevRel team)._ > > _Please also include relevant motivation and context. List any dependencies that are required for this change. Add links to Notion, Figma or any other documents that might be relevant to the PR._ It appears that we pinned the version of the JDK a long time ago because the Temurin binaries changed distribution source and no one brought this forward. This is the version of Java that our CI runners are using, so bringing the image up-to-date to address the mismatch. Fixes #`Issue Number` _or_ Fixes `Issue URL` > [!WARNING] > _If no issue exists, please create an issue first, and check with the maintainers if the issue is valid._ ## Automation /ok-to-test tags="" ### :mag: Cypress test results > [!CAUTION] > If you modify the content in this section, you are likely to disrupt the CI result for your PR. ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [x] No ## Summary by CodeRabbit * **Chores** * Enhanced Docker image builds to support multiple processor architectures, enabling deployments across diverse system configurations. --- deploy/docker/base.dockerfile | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/deploy/docker/base.dockerfile b/deploy/docker/base.dockerfile index 33a452eb67de..3fd7270cd998 100644 --- a/deploy/docker/base.dockerfile +++ b/deploy/docker/base.dockerfile @@ -46,12 +46,8 @@ ENV PATH="/usr/lib/postgresql/14/bin:${PATH}" # Install Java RUN set -o xtrace \ && mkdir -p /opt/java \ - # Assets from https://github.com/adoptium/temurin17-binaries/releases - # TODO: The release jdk-17.0.9+9.1 doesn't include Linux binaries, so this fails. - # Temporarily using hardcoded version in URL until we figure out a more elaborate/smarter solution. - #&& version="$(curl --write-out '%{redirect_url}' 'https://github.com/adoptium/temurin17-binaries/releases/latest' | sed 's,.*jdk-,,')" \ - && version="17.0.9+9" \ - && curl --location "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-$version/OpenJDK17U-jdk_$(uname -m | sed s/x86_64/x64/)_linux_hotspot_$(echo $version | tr + _).tar.gz" \ + && arch="$(uname -m | sed 's/x86_64/x64/; s/aarch64/aarch64/')" \ + && curl --location "https://api.adoptium.net/v3/binary/latest/17/ga/linux/${arch}/jdk/hotspot/normal/eclipse" \ | tar -xz -C /opt/java --strip-components 1 # Install NodeJS From 04121e42707743ebd65fdecd71dc50bbd779b974 Mon Sep 17 00:00:00 2001 From: Wyatt Walter Date: Fri, 27 Feb 2026 12:58:25 -0600 Subject: [PATCH 10/11] fix: move cookie sameSite initializer to constructor to prevent OverflowError (#41575) addCookieInitializer() was called on every setSessionId() invocation, permanently chaining Consumer.andThen() on the singleton bean's initializer field. After enough session-setting requests, the chain depth exceeded the JVM stack size, crashing SSO login with a StackOverflowError. Move the sameSite(Lax) initializer to the constructor so it's set once. Leave addCookieInitializers(exchange) as a no-op hook for EE subclasses. ## Description > [!TIP] > _Add a TL;DR when the description is longer than 500 words or extremely technical (helps the content, marketing, and DevRel team)._ > > _Please also include relevant motivation and context. List any dependencies that are required for this change. Add links to Notion, Figma or any other documents that might be relevant to the PR._ Fixes #`Issue Number` _or_ Fixes `Issue URL` > [!WARNING] > _If no issue exists, please create an issue first, and check with the maintainers if the issue is valid._ ## Automation /ok-to-test tags="" ### :mag: Cypress test results > [!CAUTION] > If you modify the content in this section, you are likely to disrupt the CI result for your PR. ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [x] No ## Summary by CodeRabbit * **Bug Fixes** * Improved session cookie configuration stability by consolidating SameSite attribute setup and preventing potential initialization accumulation issues. Co-authored-by: Claude Opus 4.6 --- .../ce/CustomCookieWebSessionIdResolverCE.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/ce/CustomCookieWebSessionIdResolverCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/ce/CustomCookieWebSessionIdResolverCE.java index 62ddf69410f3..b975924e99bc 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/ce/CustomCookieWebSessionIdResolverCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/ce/CustomCookieWebSessionIdResolverCE.java @@ -23,6 +23,7 @@ public CustomCookieWebSessionIdResolverCE() { // If the max age is not set, some browsers will default to deleting the cookies on session close. this.setCookieMaxAge(Duration.of(30, DAYS)); this.addCookieInitializer((builder) -> builder.path("/")); + this.addCookieInitializer((builder) -> builder.sameSite(LAX)); } @Override @@ -31,8 +32,16 @@ public void setSessionId(ServerWebExchange exchange, String id) { super.setSessionId(exchange, id); } + /** + * Hook for subclasses to apply per-request cookie attributes. + *

+ * WARNING: Implementations must NOT call {@link #addCookieInitializer} here. + * That method permanently appends to the singleton's Consumer chain via + * {@code Consumer.andThen()}, causing unbounded growth and eventually a + * {@link StackOverflowError}. Instead, modify cookies on the response after + * {@code super.setSessionId()} builds them. + */ protected void addCookieInitializers(ServerWebExchange exchange) { - // Add the appropriate SameSite attribute based on the exchange attribute - addCookieInitializer((builder) -> builder.sameSite(LAX)); + // No-op in CE. SameSite=Lax is set once in the constructor. } } From be5727e0742ce229aa6358bbf79e3e39e25de29e Mon Sep 17 00:00:00 2001 From: subratadeypappu Date: Mon, 2 Mar 2026 11:56:19 +0600 Subject: [PATCH 11/11] fix(server): stabilize app deletion flow to avoid resource spikes and restarts (#41584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description [Shadow EE PR](https://github.com/appsmithorg/appsmith-ee/pull/8617) # Delete Flow Diagnostics Insights ## What was added - Point-in-time diagnostics around app delete flow (`start` and `end-success/end-error`). - Captured: - JVM memory (`heap`, `non-heap`, memory pools like `G1 Eden`, `G1 Old Gen`) - Thread stats (`eventLoop`, `elastic`, `parallel`, `other`, total threads) - Reactive Mongo connection stats (`current`, `available`, `totalCreated`, `active`) - Elapsed delete duration and start/end deltas ## High-level observations from runs shared ### Earlier heavy runs (4-run batch) - Duration mostly high: ~7s to ~9.5s. - Memory behavior volatile: - 3 runs had positive heap growth (`+1.69 GiB`, `+0.89 GiB`, `+0.33 GiB`) - 1 run had heap drop (`-0.73 GiB`) - Largest spike showed strong `G1 Old Gen` growth (promotion/retention pressure). - Mongo connections were stable in most runs, but one run expanded pool (`current +16`, `available -16`). - Thread counts were broadly stable (no sustained thread leak pattern). ### Later stable runs (3-run batch) - Faster: ~1.3s to ~3.3s. - Heap mostly stable or improved: - `+17 MB`, `-28 MB`, `-13 MB` - Mongo mostly stable: - first run warmed up (`current +6`), then flat. - Threads mostly stable: - one run `+6` (eventLoop), then flat. ## What this indicates - Delete flow now shows better runtime behavior in the later runs: - lower latency - smaller/negative heap deltas - stable thread and DB connection behavior after warm-up - No consistent leak signature seen in the stable batch. ## Suggested way to report improvement - “We now have start/end diagnostics for delete flow covering memory, thread pools, and Mongo connections.” - “Compared to earlier runs, recent runs are faster and show controlled memory behavior.” - “Connection/thread growth appears warm-up related and stabilizes in subsequent runs.” ## Guardrails to monitor going forward - `elapsedMs` consistently > 5000 - `heapUsedDeltaBytes` repeatedly > `+512 MB` - repeated `G1 Old Gen` growth across consecutive deletes - `mongoCurrentConnectionsDelta` repeatedly > `+8` - monotonic rise in `totalThreadCount` across runs Fixes https://linear.app/appsmith/issue/APP-14877/appsmith-crashes-and-restarts-on-app-deletion-with-high-cpu-usage ## Automation /ok-to-test tags="@tag.All" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 6ae882148196ed14ad6772d57cb88b941b982699 > Cypress dashboard. > Tags: `@tag.All` > Spec: >


Mon, 02 Mar 2026 04:51:49 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit ## Release Notes * **Bug Fixes** * Optimized bulk archival operations for applications, action collections, actions, and pages * Enhanced concurrency controls in application deletion workflows * Improved analytics event handling to prevent deletion failures from analytics errors --- .../base/ActionCollectionServiceCE.java | 2 +- .../base/ActionCollectionServiceCEImpl.java | 12 ++++++---- .../newactions/base/NewActionServiceCE.java | 2 +- .../base/NewActionServiceCEImpl.java | 9 +++++--- .../newpages/base/NewPageServiceCE.java | 2 +- .../newpages/base/NewPageServiceCEImpl.java | 8 ++++--- .../ce/ApplicationPageServiceCEImpl.java | 23 +++++++++++++++---- 7 files changed, 40 insertions(+), 18 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/base/ActionCollectionServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/base/ActionCollectionServiceCE.java index 41d534dbc67e..5dbd03aaaf6a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/base/ActionCollectionServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/base/ActionCollectionServiceCE.java @@ -68,7 +68,7 @@ Flux getCollectionsByPageIdAndViewMode( Mono archiveById(String id); - Mono> archiveActionCollectionByApplicationId(String applicationId, AclPermission permission); + Mono archiveActionCollectionByApplicationId(String applicationId, AclPermission permission); Flux findAllActionCollectionsByContextIdAndContextTypeAndViewMode( String contextId, CreatorContextType contextType, AclPermission permission, boolean viewMode); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/base/ActionCollectionServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/base/ActionCollectionServiceCEImpl.java index 1f295e87cdec..0ce22cc3c671 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/base/ActionCollectionServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/base/ActionCollectionServiceCEImpl.java @@ -386,12 +386,16 @@ public Mono findActionCollectionDTObyIdAndViewMode( } @Override - public Mono> archiveActionCollectionByApplicationId( - String applicationId, AclPermission permission) { + public Mono archiveActionCollectionByApplicationId(String applicationId, AclPermission permission) { + // During bulk application deletion, archive action collections directly without per-entity + // analytics/audit-log events and without cascading to child actions. Child actions are separately + // archived by NewActionService.archiveActionsByApplicationId in the delete-application flow. + // This avoids the OOM caused by hundreds of concurrent audit-log DB operations that previously + // exhausted heap memory and crashed the container. return repository .findByApplicationId(applicationId, permission, null) - .flatMap(this::archiveGivenActionCollection) - .collectList(); + .flatMap(repository::archive, 4) + .then(); } @Override diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCE.java index 52f76fe5c343..d670afd14d2e 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCE.java @@ -118,7 +118,7 @@ Flux getUnpublishedActions( Mono archiveById(String id); - Mono> archiveActionsByApplicationId(String applicationId, AclPermission permission); + Mono archiveActionsByApplicationId(String applicationId, AclPermission permission); Flux getUnpublishedActionsExceptJs(MultiValueMap params); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java index 277fa54a48be..7455bc4bd01a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java @@ -1315,15 +1315,18 @@ public Mono archive(NewAction newAction) { } @Override - public Mono> archiveActionsByApplicationId(String applicationId, AclPermission permission) { + public Mono archiveActionsByApplicationId(String applicationId, AclPermission permission) { + // Limit concurrency to avoid saturating the MongoDB NIO event loop thread pool + // during bulk application deletion. Per-entity analytics are intentionally skipped here; + // only the top-level application.deleted event is logged. return repository .findByApplicationId(applicationId, permission) - .flatMap(repository::archive) + .flatMap(repository::archive, 8) .onErrorResume(throwable -> { log.error(throwable.getMessage()); return Mono.empty(); }) - .collectList(); + .then(); } private Mono updateDatasourcePolicyForPublicAction(NewAction action, Datasource datasource) { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCE.java index 11024c59725b..d4030284c0c1 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCE.java @@ -54,7 +54,7 @@ Mono findApplicationPagesByBranchedApplicationIdAndViewMode Mono findByNameAndApplicationIdAndViewMode( String name, String applicationId, AclPermission permission, Boolean view); - Mono> archivePagesByApplicationId(String applicationId, AclPermission permission); + Mono archivePagesByApplicationId(String applicationId, AclPermission permission); Mono updatePage(String pageId, PageDTO page); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCEImpl.java index 98a5c83d0297..12a05e962cf3 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCEImpl.java @@ -455,10 +455,12 @@ public Flux findNewPagesByApplicationId( } @Override - public Mono> archivePagesByApplicationId(String applicationId, AclPermission permission) { + public Mono archivePagesByApplicationId(String applicationId, AclPermission permission) { + // Limit concurrency to avoid saturating the MongoDB NIO event loop thread pool + // during bulk application deletion. return findNewPagesByApplicationId(applicationId, permission) - .flatMap(repository::archive) - .collectList(); + .flatMap(repository::archive, 4) + .then(); } @Override diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java index b8d7452d9bb7..269aad5dd0e6 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java @@ -543,10 +543,14 @@ public Mono deleteApplication(String id) { } return Flux.fromIterable(List.of(application)); }) - .flatMap(application -> { - log.debug("Archiving application with id: {}", application.getId()); - return deleteApplicationByResource(application); - }) + // Limit concurrency to avoid saturating the event loop during deletion of + // git-connected apps with many branches + .flatMap( + application -> { + log.debug("Archiving application with id: {}", application.getId()); + return deleteApplicationByResource(application); + }, + 2) .then(applicationMono) .flatMap(application -> { GitArtifactMetadata gitData = application.getGitApplicationMetadata(); @@ -592,7 +596,16 @@ protected Mono sendAppDeleteAnalytics(Application deletedApplicatio Map.of(FieldName.APP_MODE, ApplicationMode.EDIT.toString(), FieldName.APPLICATION, deletedApplication); final Map data = Map.of(FieldName.EVENT_DATA, eventData); - return analyticsService.sendDeleteEvent(deletedApplication, data); + // Run analytics/audit-log on the elastic scheduler so it does not block the + // MongoDB NIO event-loop threads, and swallow errors to avoid failing the delete. + return analyticsService + .sendDeleteEvent(deletedApplication, data) + .subscribeOn(LoadShifter.elasticScheduler) + .onErrorResume(throwable -> { + log.error( + "Error sending delete analytics for application {}", deletedApplication.getId(), throwable); + return Mono.just(deletedApplication); + }); } @Override