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/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/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/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/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..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, @@ -143,6 +150,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 +162,11 @@ function GitModals() { const isGitModEnabled = useGitModEnabled(); return isGitModEnabled ? ( - <> + - + ) : ( <> @@ -330,7 +338,6 @@ export function LeftPaneSection(props: { }, dispatch, ); - dispatch(fetchAllWorkspaces()); }; return ( @@ -475,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 @@ -482,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; @@ -677,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); @@ -711,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, @@ -1120,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 @@ -1135,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 >( @@ -1159,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/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/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/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 { 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/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; 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", 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/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/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. } } 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/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/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/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/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..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 @@ -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) { @@ -541,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(); @@ -572,6 +578,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 +587,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) { @@ -588,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 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); + } } 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); + } +} 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 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 diff --git a/deploy/helm/README.md b/deploy/helm/README.md index d1b6109784ab..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 | `{}` | @@ -95,6 +96,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: []