Repository: gravitational/teleport
Pull request #52877: Web UI: support SAML app launch URLs
Author: login: flyinghermit
Labels: name: ui
name: size/lg
name: no-changelog
description: Indicates that a PR does not require a changelog entry
name: backport/branch/v17
Created: 2025-03-07T04:20:58Z | Merged: 2025-03-20T19:08:20Z

This PR implements `<MenuLoginWithActionMenu>` button and handles launch URLs for SAML app.

Notable changes:
- `<MenuLogin>` is updated to handle `launchExternalUrl` to render `<a>` tag links instead of with the `NavLink`. `disableSearchAndFilter` props specifies to render menu item label instead of the default search/filter input. 
- `<MenuLoginWithActionMenu>` extends `<MenuLogin>` button and renders similar to `<ButtonWithMenu>` component. TO support SAML app launch URLs, we need button to show selectable URLs. 
-  If there are zero launch URL, default `<ButtonWithMenu>` is displayed with the value of SAML ACS URL. If there is only one launch URL, `<ButtonWithMenu>` border is displayed with the href value configured with the launch URL value. If there are 1+ launch URL items, `<MenuLoginWithActionMenu>` is displayed. 
- For instances where SAML action button will not be displayed, regular `<ButtonBorder>` and `<MenuLogin>` button is displayed with the login items similarly configured as explained above. 

The launch URLs can only be configured with `tctl`. Use this sample spec to configure SAML service provider for testing:
```
kind: saml_idp_service_provider
metadata:
  name: my_saml_app
spec:
  acs_url: https:/example.com
  entity_id: https:/example.com
  launch_urls:
    - https:/example.com/2
    - https:/example.com/1
version: v1
```

Note: SAML apps can be launched from Connect but this PR does not cover it. I will follow up separately. 


Implements UI for launch URL https://github.com/gravitational/teleport/issues/46416


Diff:
diff --git a/lib/web/ui/app.go b/lib/web/ui/app.go
index 934978ccb8d44..b8f6eb01c3f78 100644
--- a/lib/web/ui/app.go
+++ b/lib/web/ui/app.go
@@ -72,6 +72,9 @@ type App struct {
 	// PermissionSets holds the permission sets that this app grants access to.
 	// Only valid for Identity Center Account apps
 	PermissionSets []IdentityCenterPermissionSet `json:"permissionSets,omitempty"`
+	// SAMLAppLaunchURLs contains service provider specific authentication
+	// endpoints where user should be launched to start SAML authentication.
+	SAMLAppLaunchURLs []SAMLAppLaunchURL `json:"samlAppLaunchUrls,omitempty"`
 }
 
 // UserGroupAndDescription is a user group name and its description.
@@ -199,17 +202,27 @@ func makePermissionSets(src []*types.IdentityCenterPermissionSet) []IdentityCent
 // Web UI. Thus, this field is currently not available in the Connect App type.
 func MakeAppTypeFromSAMLApp(app types.SAMLIdPServiceProvider, c MakeAppsConfig) App {
 	labels := ui.MakeLabelsWithoutInternalPrefixes(app.GetAllLabels())
+	uiLaunchURLs := func(in []string) []SAMLAppLaunchURL {
+		out := make([]SAMLAppLaunchURL, 0, len(in))
+		for _, u := range in {
+			out = append(out, SAMLAppLaunchURL{
+				URL: u,
+			})
+		}
+		return out
+	}
 	resultApp := App{
-		Kind:            types.KindApp,
-		Name:            app.GetName(),
-		Description:     "SAML Application",
-		PublicAddr:      "",
-		Labels:          labels,
-		ClusterID:       c.AppClusterName,
-		FriendlyName:    types.FriendlyName(app),
-		SAMLApp:         true,
-		SAMLAppPreset:   cmp.Or(app.GetPreset(), "unspecified"),
-		RequiresRequest: c.RequiresRequest,
+		Kind:              types.KindApp,
+		Name:              app.GetName(),
+		Description:       "SAML Application",
+		PublicAddr:        "",
+		Labels:            labels,
+		ClusterID:         c.AppClusterName,
+		FriendlyName:      types.FriendlyName(app),
+		SAMLApp:           true,
+		SAMLAppPreset:     cmp.Or(app.GetPreset(), "unspecified"),
+		RequiresRequest:   c.RequiresRequest,
+		SAMLAppLaunchURLs: uiLaunchURLs(app.GetLaunchURLs()),
 	}
 
 	return resultApp
@@ -275,3 +288,12 @@ func MakeApps(c MakeAppsConfig) []App {
 
 	return result
 }
+
+// SAMLAppLaunchURLs contains service provider specific authentication
+// endpoints where user should be launched to start SAML authentication.
+type SAMLAppLaunchURL struct {
+	// Friendly name of the URL.
+	FriendlyName string `json:"friendlyName"`
+	// URL where the user should be landed onto.
+	URL string `json:"url,omitempty"`
+}
diff --git a/lib/web/ui/app_test.go b/lib/web/ui/app_test.go
index 9c3be45847f23..7354124316725 100644
--- a/lib/web/ui/app_test.go
+++ b/lib/web/ui/app_test.go
@@ -244,13 +244,14 @@ func TestMakeAppTypeFromSAMLApp(t *testing.T) {
 				},
 			},
 			expected: App{
-				Kind:          types.KindApp,
-				Name:          "test_app",
-				Description:   "SAML Application",
-				PublicAddr:    "",
-				Labels:        []ui.Label{},
-				SAMLApp:       true,
-				SAMLAppPreset: "unspecified",
+				Kind:              types.KindApp,
+				Name:              "test_app",
+				Description:       "SAML Application",
+				PublicAddr:        "",
+				Labels:            []ui.Label{},
+				SAMLApp:           true,
+				SAMLAppPreset:     "unspecified",
+				SAMLAppLaunchURLs: []SAMLAppLaunchURL{},
 			},
 		},
 		{
@@ -262,17 +263,19 @@ func TestMakeAppTypeFromSAMLApp(t *testing.T) {
 					},
 				},
 				Spec: types.SAMLIdPServiceProviderSpecV1{
-					Preset: "test_preset",
+					Preset:     "test_preset",
+					LaunchURLs: []string{"https://example.com"},
 				},
 			},
 			expected: App{
-				Kind:          types.KindApp,
-				Name:          "test_app",
-				Description:   "SAML Application",
-				PublicAddr:    "",
-				Labels:        []ui.Label{},
-				SAMLApp:       true,
-				SAMLAppPreset: "test_preset",
+				Kind:              types.KindApp,
+				Name:              "test_app",
+				Description:       "SAML Application",
+				PublicAddr:        "",
+				Labels:            []ui.Label{},
+				SAMLApp:           true,
+				SAMLAppPreset:     "test_preset",
+				SAMLAppLaunchURLs: []SAMLAppLaunchURL{{URL: "https://example.com"}},
 			},
 		},
 	}
diff --git a/web/packages/shared/components/MenuLogin/MenuLogin.tsx b/web/packages/shared/components/MenuLogin/MenuLogin.tsx
index be99278e42f1e..c93a940405223 100644
--- a/web/packages/shared/components/MenuLogin/MenuLogin.tsx
+++ b/web/packages/shared/components/MenuLogin/MenuLogin.tsx
@@ -25,7 +25,7 @@ import React, {
 import { NavLink } from 'react-router-dom';
 import styled from 'styled-components';
 
-import { ButtonBorder, Flex, Indicator } from 'design';
+import { ButtonBorder, Flex, Indicator, Text } from 'design';
 import { ChevronDown } from 'design/Icon';
 import Menu, { MenuItem } from 'design/Menu';
 import { space, SpaceProps } from 'design/system';
@@ -48,6 +48,7 @@ export const MenuLogin = React.forwardRef<MenuLoginHandle, MenuLoginProps>(
       inputType = MenuInputType.INPUT,
       required = true,
       width,
+      style,
     } = props;
     const [filter, setFilter] = useState('');
     const anchorRef = useRef<HTMLButtonElement>();
@@ -131,6 +132,7 @@ export const MenuLogin = React.forwardRef<MenuLoginHandle, MenuLoginProps>(
           size="small"
           setRef={anchorRef}
           onClick={onOpen}
+          style={style}
         >
           {props.buttonText || 'Connect'}
           <ChevronDown ml={1} size="small" color="text.slightlyMuted" />
@@ -154,6 +156,7 @@ export const MenuLogin = React.forwardRef<MenuLoginHandle, MenuLoginProps>(
             onClick={onItemClick}
             placeholder={placeholder}
             width={width}
+            inputType={inputType}
           />
         </Menu>
       </React.Fragment>
@@ -169,6 +172,7 @@ const LoginItemList = ({
   items,
   placeholder,
   width,
+  inputType,
 }: {
   getLoginItemsAttempt: Attempt<LoginItem[]>;
   items: LoginItem[];
@@ -177,28 +181,42 @@ const LoginItemList = ({
   onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
   placeholder: string;
   width?: string;
+  inputType?: MenuInputType;
 }) => {
   const content = getLoginItemListContent(items, getLoginItemsAttempt, onClick);
 
   return (
     <Flex flexDirection="column" minWidth={width}>
-      <Input
-        p="2"
-        m="2"
-        // this prevents safari from adding the autofill options which would cover the available logins and make it
-        // impossible to select. "But why would it do that? this isn't a username or password field?".
-        // Safari includes parsed words in the placeholder as well to determine if that autofill should show.
-        // Since our placeholder has the word "login" in it, it thinks its a login form.
-        // https://github.com/gravitational/teleport/pull/31600
-        // https://stackoverflow.com/questions/22661977/disabling-safari-autofill-on-usernames-and-passwords
-        name="notsearch_password"
-        onKeyPress={onKeyPress}
-        onChange={onChange}
-        type="text"
-        autoFocus
-        placeholder={placeholder}
-        autoComplete="off"
-      />
+      {inputType === MenuInputType.NONE ? (
+        /* css and margin value matched with AWS Launch button <RoleItemList> */
+        <Text
+          px="2"
+          mb={2}
+          typography="body3"
+          color="text.main"
+          backgroundColor="spotBackground.2"
+        >
+          {placeholder}
+        </Text>
+      ) : (
+        <Input
+          p="2"
+          m="2"
+          // this prevents safari from adding the autofill options which would cover the available logins and make it
+          // impossible to select. "But why would it do that? this isn't a username or password field?".
+          // Safari includes parsed words in the placeholder as well to determine if that autofill should show.
+          // Since our placeholder has the word "login" in it, it thinks its a login form.
+          // https://github.com/gravitational/teleport/pull/31600
+          // https://stackoverflow.com/questions/22661977/disabling-safari-autofill-on-usernames-and-passwords
+          name="notsearch_password"
+          onKeyPress={onKeyPress}
+          onChange={onChange}
+          type="text"
+          autoFocus
+          placeholder={placeholder}
+          autoComplete="off"
+        />
+      )}
       {content}
     </Flex>
   );
@@ -227,7 +245,25 @@ function getLoginItemListContent(
       return null;
     case 'success':
       return items.map((item, key) => {
-        const { login, url } = item;
+        const { login, url, isExternalUrl } = item;
+        if (isExternalUrl) {
+          return (
+            <StyledMenuItem
+              key={key}
+              as="a"
+              px="2"
+              mx="2"
+              href={url}
+              target="_blank"
+              title={login ? login : url}
+              onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
+                onClick(e, url);
+              }}
+            >
+              {login ? login : url}
+            </StyledMenuItem>
+          );
+        }
         return (
           <StyledMenuItem
             key={key}
@@ -260,6 +296,14 @@ const StyledMenuItem = styled(MenuItem)(
   border-bottom: 1px solid ${theme.colors.spotBackground[0]};
   min-height: 32px;
 
+  /* displays ellipsis for longer string value */
+  display: inline-block;
+  text-align: left;
+  max-width: 450px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+
   &:last-child {
     border-bottom: none;
     margin-bottom: 8px;
diff --git a/web/packages/shared/components/MenuLogin/types.ts b/web/packages/shared/components/MenuLogin/types.ts
index 1763289965af6..f4689f6baddae 100644
--- a/web/packages/shared/components/MenuLogin/types.ts
+++ b/web/packages/shared/components/MenuLogin/types.ts
@@ -16,13 +16,15 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-import { ComponentPropsWithRef, ComponentType } from 'react';
+import { ComponentPropsWithRef, ComponentType, CSSProperties } from 'react';
 
 import { ButtonBorder } from 'design/Button';
 
 export type LoginItem = {
   url: string;
   login: string;
+  /** Indicates url to be launched with an href attribute and in a new tab. */
+  isExternalUrl?: boolean;
 };
 
 // MenuInputType determines how the input present in the MenuLogin
@@ -33,10 +35,17 @@ export type LoginItem = {
 export enum MenuInputType {
   INPUT,
   FILTER,
+  NONE,
 }
 
 export type MenuLoginProps = {
   getLoginItems: () => LoginItem[] | Promise<LoginItem[]>;
+  /**
+   * If isExternalUrl of login item is true, a button with <a> tag is rendered
+   * and the value of url is passed for the login param. Since <a> tag with href
+   * attribute handles onClick by default, the caller may wish to
+   * pass an empty onSelect function value.
+   */
   onSelect: (e: React.SyntheticEvent, login: string) => void;
   anchorOrigin?: any;
   inputType?: MenuInputType;
@@ -48,6 +57,7 @@ export type MenuLoginProps = {
   width?: string;
   ButtonComponent?: ComponentType<ComponentPropsWithRef<typeof ButtonBorder>>;
   buttonText?: string;
+  style?: CSSProperties;
 };
 
 export type MenuLoginHandle = {
diff --git a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.story.tsx b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.story.tsx
new file mode 100644
index 0000000000000..25dd8e0ea08ae
--- /dev/null
+++ b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.story.tsx
@@ -0,0 +1,129 @@
+/**
+ * Teleport
+ * Copyright (C) 2025  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import styled from 'styled-components';
+
+import { Flex, H3, MenuItem } from 'design';
+import { MenuInputType } from 'shared/components/MenuLogin';
+
+import { MenuLoginWithActionMenu as MenuLoginWithActionMenuComponent } from './MenuLoginWithActionMenu';
+
+export default {
+  title: 'Shared/MenuLoginWithActionMenu',
+};
+
+export const MenuLoginWithActionMenu = () => {
+  return (
+    <Flex
+      inline
+      p={4}
+      gap="128px"
+      justifyContent="flex-start"
+      bg="levels.surface"
+    >
+      <Example>
+        <H3>No logins</H3>
+        <MenuLoginWithActionMenuComponent
+          getLoginItems={() => []}
+          onSelect={() => null}
+          buttonText="Connect"
+          size="small"
+        >
+          {menuItems}
+        </MenuLoginWithActionMenuComponent>
+      </Example>
+      <Example>
+        <H3>Processing state</H3>
+        <MenuLoginWithActionMenuComponent
+          getLoginItems={() => new Promise(() => {})}
+          onSelect={() => null}
+          buttonText="Connect"
+          size="small"
+        >
+          {menuItems}
+        </MenuLoginWithActionMenuComponent>
+      </Example>
+      <Example>
+        <H3>With logins</H3>
+        <MenuLoginWithActionMenuComponent
+          size="small"
+          buttonText="Connect"
+          getLoginItems={() => urlItems}
+          onSelect={() => null}
+          placeholder="Select item to log in..."
+          inputType={MenuInputType.NONE}
+        >
+          {menuItems}
+        </MenuLoginWithActionMenuComponent>
+      </Example>
+      <Example>
+        <H3>With logins and search input</H3>
+        <MenuLoginWithActionMenuComponent
+          buttonText="Connect"
+          size="small"
+          getLoginItems={() => loginItems}
+          onSelect={() => null}
+          placeholder="search login"
+        >
+          {menuItems}
+        </MenuLoginWithActionMenuComponent>
+      </Example>
+      <Example>
+        <H3>Large button with custom width</H3>
+        <MenuLoginWithActionMenuComponent
+          size="large"
+          width="150px"
+          buttonText="Connect"
+          getLoginItems={() => urlItems}
+          onSelect={() => null}
+          placeholder="Select item to log in..."
+        >
+          {menuItems}
+        </MenuLoginWithActionMenuComponent>
+      </Example>
+    </Flex>
+  );
+};
+
+const Example = styled(Flex).attrs({
+  gap: 2,
+  flexDirection: 'column',
+  alignItems: 'flex-start',
+})``;
+
+const makeLoginItem = (login: string) => ({ url: '', login });
+
+const urlItems = [
+  'https://portal.azure.com/xxxxx2432-xxx-xx-xx-cxxxxx37ded',
+  'https://portal.azure.com/xxxxx2432-xxx-xx-xx-cxxxxx37ded&login_hint=user@example.com',
+  'https://myapplications.microsoft.com/?tenantid=xxxxx2432-xxx-xx-xx-cxxxxx37ded',
+].map(makeLoginItem);
+
+const loginItems = ['alice', 'bob', 'hector'].map(makeLoginItem);
+
+const menuItems = (
+  <>
+    <MenuItem onClick={() => alert('Foo')}>Foo</MenuItem>
+    <MenuItem as="a" href="https://example.com" target="_blank">
+      Link to example.com
+    </MenuItem>
+    <MenuItem onClick={() => alert('Lorem ipsum dolor sit amet')}>
+      Lorem ipsum dolor sit amet
+    </MenuItem>
+  </>
+);
diff --git a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.test.tsx b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.test.tsx
new file mode 100644
index 0000000000000..e737d05644fb2
--- /dev/null
+++ b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.test.tsx
@@ -0,0 +1,87 @@
+/**
+ * Teleport
+ * Copyright (C) 2023  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { MenuItem } from 'design';
+import { fireEvent, render, screen } from 'design/utils/testing';
+import { MenuInputType } from 'shared/components/MenuLogin';
+
+import { MenuLoginWithActionMenu } from './MenuLoginWithActionMenu';
+
+test('clicking on primary menu and secondary menu opens respective menu items', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [{ url: '', login: 'alice' }]}
+      onSelect={() => null}
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  expect(await screen.findByText('alice')).toBeInTheDocument();
+
+  fireEvent.click(screen.getByTitle('Open menu'));
+  expect(await screen.findByText('Menu item')).toBeInTheDocument();
+});
+
+test('filter input field should be visible by default', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [
+        { url: '', login: 'alice' },
+        { url: '', login: 'bob' },
+      ]}
+      onSelect={() => null}
+      placeholder="search me"
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  expect(await screen.findByText('alice')).toBeInTheDocument();
+
+  expect(screen.getByPlaceholderText('search me')).toBeInTheDocument();
+});
+
+test('MenuInputType.NONE should show static menu item label', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [
+        { url: '', login: 'alice' },
+        { url: '', login: 'bob' },
+      ]}
+      onSelect={() => null}
+      placeholder="search me"
+      inputType={MenuInputType.NONE}
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  expect(await screen.findByText('alice')).toBeInTheDocument();
+
+  // Verify that placeholder is used as a text label above menu and
+  // not as an actual placeholder.
+  expect(screen.getByText('search me')).toBeInTheDocument();
+  expect(screen.queryByPlaceholderText('search me')).not.toBeInTheDocument();
+});
diff --git a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
new file mode 100644
index 0000000000000..7c29494ce00d1
--- /dev/null
+++ b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
@@ -0,0 +1,139 @@
+/**
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { ReactElement, useRef, useState } from 'react';
+
+import { ButtonBorder, Flex, Menu, MenuItem } from 'design';
+import { ButtonSize } from 'design/Button';
+import * as icons from 'design/Icon';
+import {
+  LoginItem,
+  MenuInputType,
+  MenuLogin,
+} from 'shared/components/MenuLogin';
+
+/**
+ * Displays a menu button with additional action menu attached to the right of it.
+ * <MenuLoginWithActionMenu> expects two children, a LoginItem for <MenuLogin> and <MenuItem>
+ * for action memnu.
+ *
+ * @example
+ * <MenuLoginWithActionMenu
+ *   buttonText="Log In"
+ *   size="medium"
+ *   onSelect={() => alert('item selected')}
+ *   getLoginItems= {() => LoginItem[]}
+ * >
+ *   <MenuItem>Foo</MenuItem>
+ *   <MenuItem>Bar</MenuItem>
+ * </MenuLoginWithActionMenu>
+ */
+export const MenuLoginWithActionMenu = ({
+  buttonText,
+  onSelect,
+  getLoginItems,
+  children,
+  width,
+  size = 'medium',
+  placeholder,
+  inputType,
+}: {
+  /** Button text for main menu button. */
+  buttonText: string;
+  /**
+   * Handles select or click in main menu items.
+   * If isExternalUrl item returned by getLoginItems is true, a button with <a> tag is rendered
+   * and the value of url is passed for the login param. Since <a> tag with href
+   * attribute handles onClick by default, the caller may wish to
+   * pass an empty onSelect function value.
+   */
+  onSelect: (e: React.SyntheticEvent, login: string) => void;
+  /** Fetches login items. */
+  getLoginItems: () => LoginItem[] | Promise<LoginItem[]>;
+  /** Action menu items. */
+  children: MenuItemComponent | MenuItemComponent[];
+  width?: string;
+  size?: ButtonSize;
+  /** Text for action menu search box or static label.  */
+  placeholder?: string;
+  /** Input type for menu item filter input. */
+  inputType?: MenuInputType;
+}) => {
+  const moreButtonRef = useRef<HTMLButtonElement>(null);
+  const [isOpen, setIsOpen] = useState(false);
+  return (
+    <Flex>
+      <MenuLogin
+        width={width}
+        inputType={inputType}
+        onSelect={onSelect}
+        textTransform="none"
+        alignButtonWidthToMenu
+        getLoginItems={getLoginItems}
+        transformOrigin={{
+          vertical: 'top',
+          horizontal: 'right',
+        }}
+        anchorOrigin={{
+          vertical: 'bottom',
+          horizontal: 'right',
+        }}
+        style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
+        buttonText={buttonText}
+        placeholder={placeholder}
+      />
+      <ButtonBorder
+        setRef={moreButtonRef}
+        px={1}
+        size={size}
+        onClick={() => setIsOpen(true)}
+        /* css value matches with ButtonWithMenu component */
+        css={`
+          border-left: none;
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        `}
+        title="Open menu"
+      >
+        <icons.MoreVert size={size} color="text.slightlyMuted" />
+      </ButtonBorder>
+      <Menu
+        anchorEl={moreButtonRef.current}
+        open={isOpen}
+        onClose={() => setIsOpen(false)}
+        getContentAnchorEl={null}
+        anchorOrigin={{
+          vertical: 'bottom',
+          horizontal: 'right',
+        }}
+        transformOrigin={{
+          vertical: 'top',
+          horizontal: 'right',
+        }}
+        menuListProps={{ onClick: () => setIsOpen(false) }}
+      >
+        {children}
+      </Menu>
+    </Flex>
+  );
+};
+
+// TODO(ravicious,sshah): At the moment, this doesn't accomplish much – it only prevents MenuLoginWithActionMenu
+// from MenuLoginWithActionMenu strings as children. Once styled-components are typed, it should enforce that
+// MenuLoginWithActionMenu accepts only MenuItem as children.
+type MenuItemComponent = ReactElement<typeof MenuItem>;
diff --git a/web/packages/shared/components/MenuLoginWithActionMenu/index.ts b/web/packages/shared/components/MenuLoginWithActionMenu/index.ts
new file mode 100644
index 0000000000000..b131fe545010e
--- /dev/null
+++ b/web/packages/shared/components/MenuLoginWithActionMenu/index.ts
@@ -0,0 +1,19 @@
+/**
+ * Teleport
+ * Copyright (C) 2025  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+export { MenuLoginWithActionMenu } from './MenuLoginWithActionMenu';
diff --git a/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx b/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx
index 2973f0f5855b8..600dcede86d15 100644
--- a/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx
+++ b/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx
@@ -17,6 +17,7 @@
  */
 
 import { Meta, StoryObj } from '@storybook/react';
+import { MemoryRouter } from 'react-router';
 import styled from 'styled-components';
 
 import { ButtonBorder } from 'design';
@@ -97,51 +98,53 @@ const ActionButton = <ButtonBorder size="small">Action</ButtonBorder>;
 export const Cards: Story = {
   render() {
     return (
-      <SamlAppActionProvider>
-        <Grid gap={2}>
-          {[
-            ...apps.map(resource =>
-              makeUnifiedResourceViewItemApp(resource, {
-                ActionButton: <ResourceActionButton resource={resource} />,
-              })
-            ),
-            ...databases.map(resource =>
-              makeUnifiedResourceViewItemDatabase(resource, {
-                ActionButton,
-              })
-            ),
-            ...kubes.map(resource =>
-              makeUnifiedResourceViewItemKube(resource, { ActionButton })
-            ),
-            ...nodes.map(resource =>
-              makeUnifiedResourceViewItemNode(resource, {
-                ActionButton,
-              })
-            ),
-            ...additionalResources.map(resource =>
-              makeUnifiedResourceViewItemApp(resource, { ActionButton })
-            ),
-            ...desktops.map(resource =>
-              makeUnifiedResourceViewItemDesktop(resource, { ActionButton })
-            ),
-          ].map((res, i) => (
-            <ResourceCard
-              key={i}
-              pinned={false}
-              pinResource={() => {}}
-              selectResource={() => {}}
-              selected={false}
-              pinningSupport={PinningSupport.Supported}
-              name={res.name}
-              primaryIconName={res.primaryIconName}
-              SecondaryIcon={res.SecondaryIcon}
-              cardViewProps={res.cardViewProps}
-              labels={res.labels}
-              ActionButton={res.ActionButton}
-            />
-          ))}
-        </Grid>
-      </SamlAppActionProvider>
+      <MemoryRouter>
+        <SamlAppActionProvider>
+          <Grid gap={2}>
+            {[
+              ...apps.map(resource =>
+                makeUnifiedResourceViewItemApp(resource, {
+                  ActionButton: <ResourceActionButton resource={resource} />,
+                })
+              ),
+              ...databases.map(resource =>
+                makeUnifiedResourceViewItemDatabase(resource, {
+                  ActionButton,
+                })
+              ),
+              ...kubes.map(resource =>
+                makeUnifiedResourceViewItemKube(resource, { ActionButton })
+              ),
+              ...nodes.map(resource =>
+                makeUnifiedResourceViewItemNode(resource, {
+                  ActionButton,
+                })
+              ),
+              ...additionalResources.map(resource =>
+                makeUnifiedResourceViewItemApp(resource, { ActionButton })
+              ),
+              ...desktops.map(resource =>
+                makeUnifiedResourceViewItemDesktop(resource, { ActionButton })
+              ),
+            ].map((res, i) => (
+              <ResourceCard
+                key={i}
+                pinned={false}
+                pinResource={() => {}}
+                selectResource={() => {}}
+                selected={false}
+                pinningSupport={PinningSupport.Supported}
+                name={res.name}
+                primaryIconName={res.primaryIconName}
+                SecondaryIcon={res.SecondaryIcon}
+                cardViewProps={res.cardViewProps}
+                labels={res.labels}
+                ActionButton={res.ActionButton}
+              />
+            ))}
+          </Grid>
+        </SamlAppActionProvider>
+      </MemoryRouter>
     );
   },
 };
diff --git a/web/packages/teleport/src/Apps/fixtures/index.ts b/web/packages/teleport/src/Apps/fixtures/index.ts
index 4b9fc1f7ef19d..1bf4709101c24 100644
--- a/web/packages/teleport/src/Apps/fixtures/index.ts
+++ b/web/packages/teleport/src/Apps/fixtures/index.ts
@@ -177,6 +177,27 @@ export const apps = [
     samlApp: true,
     samlAppSSOUrl: '',
   },
+  {
+    kind: 'app',
+    name: 'saml app with launch urls',
+    uri: '',
+    publicAddr: '',
+    description: 'SAML Application',
+    awsConsole: false,
+    labels: [],
+    clusterId: 'one',
+    fqdn: '',
+    samlApp: true,
+    samlAppSsoUrl: '',
+    id: 'saml_app_launch_url.teleport.com',
+    launchUrl: '',
+    awsRoles: [],
+    userGroups: [],
+    samlAppLaunchUrls: [
+      { url: 'https://example.com' },
+      { url: 'https://2.example.com' },
+    ],
+  },
   {
     name: 'okta',
     uri: '',
diff --git a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.story.tsx b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.story.tsx
index 87932613688b3..a91f5b6621664 100644
--- a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.story.tsx
+++ b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.story.tsx
@@ -108,6 +108,22 @@ export function ResourceActionButton() {
             })}
           />
         </Stack>
+        <Stack>
+          <Text>SAML app with launch URLs</Text>
+          <Component
+            resource={makeApp({
+              uri: 'http://localhost:300',
+              publicAddr: 'saml-app.teleport.example.com',
+              fqdn: 'saml-app.teleport.example.com',
+              name: 'saml-app',
+              samlApp: true,
+              samlAppLaunchUrls: [
+                { url: 'https://example.com' },
+                { url: 'https://example.com/1' },
+              ],
+            })}
+          />
+        </Stack>
       </Stack>
 
       <Stack>
diff --git a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.test.tsx b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.test.tsx
new file mode 100644
index 0000000000000..c8def2efac5e7
--- /dev/null
+++ b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.test.tsx
@@ -0,0 +1,99 @@
+/**
+ * Teleport
+ * Copyright (C) 2025  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { MemoryRouter } from 'react-router';
+
+import { fireEvent, render, screen, waitFor } from 'design/utils/testing';
+
+import { SamlAppActionProvider } from 'teleport/SamlApplications/useSamlAppActions';
+import { App } from 'teleport/services/apps';
+
+import { ResourceActionButton } from './ResourceActionButton';
+
+test('saml app: empty launch url should display <a> link with href value pointed to samlAppSsoUrl', () => {
+  render(
+    <SamlAppActionProvider>
+      <ResourceActionButton resource={resource} />
+    </SamlAppActionProvider>
+  );
+
+  expect(screen.getByRole('link', { name: /Log In/i })).toHaveAttribute(
+    'href',
+    resource.samlAppSsoUrl
+  );
+});
+
+test('saml app: launch url with one item should display <a> link with href value pointed to first launch url item', () => {
+  const samlApp = resource;
+  samlApp.samlAppLaunchUrls = [{ url: 'https://example.com' }];
+  render(
+    <MemoryRouter>
+      <SamlAppActionProvider>
+        <ResourceActionButton resource={samlApp} />
+      </SamlAppActionProvider>
+    </MemoryRouter>
+  );
+
+  expect(screen.getByRole('link', { name: /Log In/i })).toHaveAttribute(
+    'href',
+    samlApp.samlAppLaunchUrls[0].url
+  );
+});
+
+test('saml app: multiple launch urls should display menu login button', async () => {
+  const samlApp = resource;
+  samlApp.samlAppLaunchUrls = [
+    { url: 'https://example.com' },
+    { url: 'https://2.example.com' },
+  ];
+  render(
+    <MemoryRouter>
+      <SamlAppActionProvider>
+        <ResourceActionButton resource={samlApp} />
+      </SamlAppActionProvider>
+    </MemoryRouter>
+  );
+
+  fireEvent.click(screen.getByRole('button', { name: /Log In/i }));
+  await waitFor(() => {
+    expect(
+      screen.getByText(samlApp.samlAppLaunchUrls[0].url)
+    ).toBeInTheDocument();
+  });
+  expect(
+    screen.getByText(samlApp.samlAppLaunchUrls[1].url)
+  ).toBeInTheDocument();
+});
+
+const resource: App = {
+  kind: 'app',
+  name: 'saml_app',
+  uri: '',
+  publicAddr: '',
+  description: 'SAML Application',
+  awsConsole: false,
+  labels: [],
+  clusterId: 'one',
+  fqdn: '',
+  samlApp: true,
+  samlAppSsoUrl: 'https://sp.example.com',
+  id: 'saml_app.teleport.com',
+  launchUrl: '',
+  awsRoles: [],
+  userGroups: [],
+};
diff --git a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
index ad2b4e02d7240..b46886efe65c9 100644
--- a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
+++ b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
@@ -26,6 +26,7 @@ import {
   MenuInputType,
   MenuLogin,
 } from 'shared/components/MenuLogin';
+import { MenuLoginWithActionMenu } from 'shared/components/MenuLoginWithActionMenu';
 import { AwsRole } from 'shared/services/apps';
 
 import { TcpAppConnectDialog } from 'teleport/Apps/TcpAppConnectDialog';
@@ -38,7 +39,7 @@ import KubeConnectDialog from 'teleport/Kubes/ConnectDialog';
 import { openNewTab } from 'teleport/lib/util';
 import { useSamlAppAction } from 'teleport/SamlApplications/useSamlAppActions';
 import { UnifiedResource } from 'teleport/services/agents';
-import { App, AppSubKind } from 'teleport/services/apps';
+import { App, AppSubKind, SamlAppLaunchUrl } from 'teleport/services/apps';
 import { Database } from 'teleport/services/databases';
 import { Desktop } from 'teleport/services/desktops';
 import { Kube } from 'teleport/services/kube';
@@ -173,6 +174,7 @@ const AppLaunch = ({ app }: AppLaunchProps) => {
     samlAppPreset,
     subKind,
     permissionSets,
+    samlAppLaunchUrls,
   } = app;
   const { actions, userSamlIdPPerm } = useSamlAppAction();
 
@@ -249,18 +251,8 @@ const AppLaunch = ({ app }: AppLaunchProps) => {
         icon: 'application',
         keywords: ['saml'],
       };
-      return (
-        <ButtonWithMenu
-          text="Log In"
-          width="123px"
-          size="small"
-          target="_blank"
-          href={samlAppSsoUrl}
-          rel="noreferrer"
-          textTransform="none"
-          forwardedAs="a"
-          title="Log in to SAML application"
-        >
+      const samlAppActionMenu = (
+        <>
           <MenuItem
             onClick={() => actions.startEdit(currentSamlAppSpec)}
             disabled={!userSamlIdPPerm.edit} // disable props does not disable onClick
@@ -273,23 +265,15 @@ const AppLaunch = ({ app }: AppLaunchProps) => {
           >
             Delete
           </MenuItem>
-        </ButtonWithMenu>
+        </>
       );
-    } else {
-      return (
-        <ButtonBorder
-          as="a"
-          width="123px"
-          size="small"
-          target="_blank"
-          href={samlAppSsoUrl}
-          rel="noreferrer"
-          textTransform="none"
-          title="Log in to SAML application"
-        >
-          Log In
-        </ButtonBorder>
+      return makeSamlAppLoginWithMenuButton(
+        samlAppSsoUrl,
+        samlAppLaunchUrls,
+        samlAppActionMenu
       );
+    } else {
+      return makeSamlAppLoginButton(samlAppSsoUrl, samlAppLaunchUrls);
     }
   }
   return (
@@ -462,3 +446,129 @@ const makeDesktopLoginOptions = (
     };
   });
 };
+
+/**
+ * makeSamlAppLoginWithMenuButton returns login button for SAML apps.
+ * If launchUrls is not empty and its length is greater than one,
+ * MenuLoginWithActionMenu button is returned.
+ * if launchUrls is not empty but its length is exactly one, ButtonWithMenu
+ * is returned with the href value configured with the launchUrls[0] value.
+ * If launchUrls is empty, ButtonWithMenu is returned with the href value
+ * configured with samlAppSsoUrl.
+ * @param samlAppSsoUrl - ACS URL (also known as SSO endpoint) value for SAML app.
+ * @param launchUrls - custom service provider endpoints that will be used to initiate
+ *                     authentication, instead of the ACS URL.
+ * @param SamlActionMenu - SAML app edit and delete action menu items.
+ */
+function makeSamlAppLoginWithMenuButton(
+  samlAppSsoUrl: string,
+  launchUrls: SamlAppLaunchUrl[],
+  SamlActionMenu: JSX.Element
+) {
+  let ssoUrl = samlAppSsoUrl;
+  if (launchUrls) {
+    if (launchUrls.length > 1) {
+      return (
+        <MenuLoginWithActionMenu
+          getLoginItems={() => makeSamlAppLoginOptions(launchUrls)}
+          width="100px"
+          onSelect={() => {}} // login item rendered as <a> link for saml apps.
+          buttonText="Log In"
+          size="small"
+          inputType={MenuInputType.NONE}
+          placeholder="Select URL to log in"
+        >
+          {SamlActionMenu}
+        </MenuLoginWithActionMenu>
+      );
+    }
+    ssoUrl = launchUrls[0].url;
+  }
+
+  return (
+    <ButtonWithMenu
+      text="Log In"
+      width="100px"
+      size="small"
+      target="_blank"
+      href={ssoUrl}
+      rel="noreferrer"
+      textTransform="none"
+      forwardedAs="a"
+      title="Log in to SAML application"
+    >
+      {SamlActionMenu}
+    </ButtonWithMenu>
+  );
+}
+
+/**
+ * makeSamlAppLoginButton returns login button for SAML apps.
+ * If launchUrls is not empty and its length is greater than one,
+ * MenuLogin button is returned.
+ * if launchUrls is not empty but its length is exactly one, ButtonBorder
+ * is returned with the href value configured with the launchUrls[0] value.
+ * If launchUrls is empty, ButtonBorder is returned with the href value
+ * configured with samlAppSsoUrl.
+ * @param samlAppSsoUrl - ACS URL (also known as SSO endpoint) value for SAML app.
+ * @param launchUrls - custom service provider endpoints that will be used to initiate
+ *                     authentication, instead of the ACS URL.
+ */
+function makeSamlAppLoginButton(
+  samlAppSsoUrl: string,
+  launchUrls: SamlAppLaunchUrl[]
+) {
+  let ssoUrl = samlAppSsoUrl;
+  if (launchUrls) {
+    if (launchUrls.length > 1) {
+      return (
+        <MenuLogin
+          width="100px"
+          inputType={MenuInputType.NONE}
+          onSelect={() => {}} // login item rendered as <a> link for saml apps.
+          textTransform="none"
+          alignButtonWidthToMenu
+          getLoginItems={() => makeSamlAppLoginOptions(launchUrls)}
+          transformOrigin={{
+            vertical: 'top',
+            horizontal: 'right',
+          }}
+          anchorOrigin={{
+            vertical: 'bottom',
+            horizontal: 'right',
+          }}
+          buttonText="Log In"
+          placeholder="Select URL to log in"
+        />
+      );
+    }
+    ssoUrl = launchUrls[0].url;
+  }
+
+  return (
+    <ButtonBorder
+      as="a"
+      width="100px"
+      size="small"
+      target="_blank"
+      href={ssoUrl}
+      rel="noreferrer"
+      textTransform="none"
+      title="Log in to SAML application"
+    >
+      Log In
+    </ButtonBorder>
+  );
+}
+
+const makeSamlAppLoginOptions = (
+  launchUrls: SamlAppLaunchUrl[]
+): LoginItem[] => {
+  return launchUrls.map(u => {
+    return {
+      login: u.friendlyName ? u.friendlyName : u.url,
+      url: u.url,
+      isExternalUrl: true,
+    };
+  });
+};
diff --git a/web/packages/teleport/src/services/apps/apps.test.ts b/web/packages/teleport/src/services/apps/apps.test.ts
index ea68306dfab66..dfa85df90e34f 100644
--- a/web/packages/teleport/src/services/apps/apps.test.ts
+++ b/web/packages/teleport/src/services/apps/apps.test.ts
@@ -121,6 +121,7 @@ test('correct formatting of apps fetch response', async () => {
         samlAppPreset: 'gcp-workforce',
         integration: '',
         permissionSets: [],
+        samlAppLaunchUrls: [{ url: 'https://example.com' }],
       },
     ],
     startKey: mockResponse.startKey,
@@ -204,6 +205,7 @@ const mockResponse = {
       description: 'SAML Application',
       samlApp: true,
       samlAppPreset: 'gcp-workforce',
+      samlAppLaunchUrls: [{ url: 'https://example.com' }],
     },
   ],
   startKey: 'mockKey',
diff --git a/web/packages/teleport/src/services/apps/makeApps.ts b/web/packages/teleport/src/services/apps/makeApps.ts
index 66f27634e3c60..fd67205d6a497 100644
--- a/web/packages/teleport/src/services/apps/makeApps.ts
+++ b/web/packages/teleport/src/services/apps/makeApps.ts
@@ -38,6 +38,7 @@ export default function makeApp(json: any): App {
     integration = '',
     samlAppPreset,
     subKind,
+    samlAppLaunchUrls,
   } = json;
 
   const canCreateUrl = fqdn && clusterId && publicAddr;
@@ -96,5 +97,6 @@ export default function makeApp(json: any): App {
     requiresRequest,
     integration,
     permissionSets,
+    samlAppLaunchUrls,
   };
 }
diff --git a/web/packages/teleport/src/services/apps/types.ts b/web/packages/teleport/src/services/apps/types.ts
index 5ab1be3a6edcf..946f5afa62332 100644
--- a/web/packages/teleport/src/services/apps/types.ts
+++ b/web/packages/teleport/src/services/apps/types.ts
@@ -59,6 +59,11 @@ export interface App {
    * aws_ic_account.
    */
   permissionSets?: PermissionSet[];
+  /**
+   * SamlAppLaunchUrl contains service provider specific authentication
+   * endpoints where user should be launched to start SAML authentication.
+   */
+  samlAppLaunchUrls?: SamlAppLaunchUrl[];
 }
 
 export type UserGroupAndDescription = {
@@ -83,3 +88,14 @@ export type PermissionSet = {
   /** assignmentId is an account assignment ID. */
   assignmentId: string;
 };
+
+/**
+ * SamlAppLaunchUrl contains service provider specific authentication
+ * endpoint where user should be launched to start SAML authentication.
+ */
+export type SamlAppLaunchUrl = {
+  /* launch URL. */
+  url: string;
+  /* friendly name of the URL. */
+  friendlyName?: string;
+};


Reviews:
user: login: avatus
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T15:15:55Z
user: login: ravicious
body: I'll continue the review tomorrow.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T17:11:54Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T00:18:05Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T03:14:51Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T03:22:55Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T03:24:55Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T03:33:09Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T03:57:34Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T13:46:42Z
user: login: ravicious
body: I submitted a bunch of minor issues, LGTM once they are addressed.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T17:58:12Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T23:41:55Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T00:13:31Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T00:29:23Z
user: login: flyinghermit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T01:29:50Z

Review comments:
diff_hunk: @@ -199,17 +202,27 @@ func makePermissionSets(src []*types.IdentityCenterPermissionSet) []IdentityCent
 // Web UI. Thus, this field is currently not available in the Connect App type.
 func MakeAppTypeFromSAMLApp(app types.SAMLIdPServiceProvider, c MakeAppsConfig) App {
 	labels := ui.MakeLabelsWithoutInternalPrefixes(app.GetAllLabels())
+	uiLaunchURLs := func(in []string) []SAMLAppLaunchURL {
+		out := make([]SAMLAppLaunchURL, 0, len(in))
+		for _, u := range in {
+			out = append(out, SAMLAppLaunchURL{
+				URL: u,
+			})
path: lib/web/ui/app.go
user: login: avatus
body: should we be using the `FriendlyName` field anywhere here? I don't think i see it used, curious if it might be vestigial from a previous iteration.
created_at: 2025-03-17T15:05:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,138 @@
+/**
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { ReactElement, useRef, useState } from 'react';
+
+import { ButtonBorder, Flex, Menu, MenuItem } from 'design';
+import { ButtonSize } from 'design/Button';
+import * as icons from 'design/Icon';
+import {
+  LoginItem,
+  MenuInputType,
+  MenuLogin,
+} from 'shared/components/MenuLogin';
+
+/**
+ * Displays a menu button with additional action menu attached to the right of it.
+ * <MenuLoginWithActionMenu> expects two children, a LoginItem for <MenuLogin> and <MenuItem>
+ * for action memnu.
+ *
+ * @param buttonText - button text for main menu button.
+ * @param onSelect - handles select or click in main menu items.
+ * @param getLoginItems - fetches login items.
+ * @param children - action menu items.
+ * @param placeholder - text for action menu search box or static label.
+ * @param size - button icon size.
+ * @param width - button width.
+ * @param disableSearchAndFilter - instructs underlying MenuLogin component to display static
+ *                                 label text instead of default search box.
+ *
+ * @example
+ * <MenuLoginWithActionMenu
+ *   buttonText="Log In"
+ *   size="medium"
+ *   onSelect={() => alert('item selected')}
+ *   getLoginItems= {() => LoginItem[]}
+ * >
+ *   <MenuItem>Foo</MenuItem>
+ *   <MenuItem>Bar</MenuItem>
+ * </MenuLoginWithActionMenu>
+ */
+export const MenuLoginWithActionMenu = ({
+  buttonText,
+  onSelect,
+  getLoginItems,
+  children,
+  width,
+  size = 'medium',
+  placeholder,
+  disableSearchAndFilter,
+  launchExternalUrl,
+}: {
+  buttonText: string;
+  onSelect: (e: React.SyntheticEvent, login: string) => void;
+  getLoginItems: () => LoginItem[] | Promise<LoginItem[]>;
+  children: MenuItemComponent | MenuItemComponent[];
+  width?: string;
+  size?: ButtonSize;
+  placeholder?: string;
+  disableSearchAndFilter?: boolean;
+  launchExternalUrl?: boolean;
+}) => {
+  const moreButtonRef = useRef<HTMLButtonElement>(null);
+  const [isOpen, setIsOpen] = useState(false);
+  return (
+    <Flex>
+      <MenuLogin
+        width={width}
+        inputType={MenuInputType.FILTER}
+        onSelect={onSelect}
+        textTransform="none"
+        alignButtonWidthToMenu
+        getLoginItems={getLoginItems}
+        transformOrigin={{
+          vertical: 'top',
+          horizontal: 'right',
+        }}
+        anchorOrigin={{
+          vertical: 'bottom',
+          horizontal: 'right',
+        }}
+        style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
+        buttonText={buttonText}
+        disableSearchAndFilter={disableSearchAndFilter}
+        launchExternalUrl={launchExternalUrl}
+        placeholder={placeholder}
+      />
+      <ButtonBorder
+        setRef={moreButtonRef}
+        px={1}
+        size={size}
+        onClick={() => setIsOpen(true)}
+        /* css value matches with ButtonWithMenu component */
+        css={`
+          border-left: none;
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        `}
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
user: login: avatus
body: i wonder if there is a way to make this a constant or be in the styles or something so we _know_ it matches, rather than hardcoding and hoping it doesn't drift
created_at: 2025-03-17T15:13:12Z
author_association: CONTRIBUTOR
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
user: login: ravicious
body: What's the reason behind `MenuLoginWithActionMenu` being bigger than `MenuLogin`? I'd expect them to be the same size.

<img width="1360" alt="MenuLogin vs MenuLoginWithActionMenu" src="https://github.com/user-attachments/assets/3ef1d719-5ea2-4c55-b399-b4b93b48f89b" />

created_at: 2025-03-17T16:34:11Z
author_association: MEMBER
path: web/packages/shared/components/MenuLogin/MenuLogin.tsx
user: login: ravicious
body: On master, menu items are aligned to the left, but the changes in this branch make them aligned to the center.

<img width="1466" alt="text-alignment" src="https://github.com/user-attachments/assets/06abb2b2-e041-4f01-8213-2fbe3bcc0c5e" />

created_at: 2025-03-17T16:36:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,115 @@
+/**
+ * Teleport
+ * Copyright (C) 2025  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import styled from 'styled-components';
+
+import { Flex, H3, MenuItem } from 'design';
+
+import { MenuLoginWithActionMenu as MenuLoginWithActionMenuComponent } from './MenuLoginWithActionMenu';
+
+export default {
+  title: 'Shared/MenuLoginWithActionMenu',
+};
+
+export const MenuLoginWithActionMenu = () => {
+  return (
+    <Flex
+      inline
+      p={4}
+      gap="128px"
+      justifyContent="flex-start"
+      bg="levels.surface"
+    >
+      <Example>
+        <H3>No logins</H3>
+        <MenuLoginWithActionMenuComponent
+          getLoginItems={() => []}
+          width="100px"
+          onSelect={() => null}
+          buttonText="click me"
+        >
+          {MenuItems}
+        </MenuLoginWithActionMenuComponent>
+      </Example>
+      <Example>
+        <H3>Processing state</H3>
+        <MenuLoginWithActionMenuComponent
+          width="100px"
+          getLoginItems={() => new Promise(() => {})}
+          onSelect={() => null}
+          buttonText="click me"
+        >
+          {MenuItems}
+        </MenuLoginWithActionMenuComponent>
+      </Example>
+      <Example>
+        <H3>With logins</H3>
+        <MenuLoginWithActionMenuComponent
+          width="100px"
+          buttonText="click me"
+          getLoginItems={() => urlItems}
+          onSelect={() => null}
+          placeholder="Select item to log in..."
+        >
+          {MenuItems}
+        </MenuLoginWithActionMenuComponent>
+      </Example>
+      <Example>
+        <H3>With logins and search input</H3>
+        <MenuLoginWithActionMenuComponent
+          width="100px"
+          buttonText="click me"
+          getLoginItems={() => loginItems}
+          onSelect={() => null}
+          placeholder="search login"
+          disableSearchAndFilter={true}
+        >
+          {MenuItems}
+        </MenuLoginWithActionMenuComponent>
+      </Example>
+    </Flex>
+  );
+};
+
+const Example = styled(Flex).attrs({
+  gap: 2,
+  flexDirection: 'column',
+  alignItems: 'flex-start',
+})``;
+
+const makeLoginItem = (login: string) => ({ url: '', login });
+
+const urlItems = [
+  'https://portal.azure.com/xxxxx2432-xxx-xx-xx-cxxxxx37ded',
+  'https://portal.azure.com/xxxxx2432-xxx-xx-xx-cxxxxx37ded&login_hint=user@example.com',
+  'https://myapplications.microsoft.com/?tenantid=xxxxx2432-xxx-xx-xx-cxxxxx37ded',
+].map(makeLoginItem);
+
+const loginItems = ['alice', 'bob', 'hector'].map(makeLoginItem);
+
+const MenuItems = (
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.story.tsx
user: login: ravicious
body: Nit: Pascal case names are typically reserved for components, that is functions that return JSX. Here I'd go for `$menuItems` or just `menuItems`.
created_at: 2025-03-17T16:38:19Z
author_association: MEMBER
diff_hunk: @@ -455,6 +441,7 @@ function makeSamlAppLoginButton(
           buttonText="Log In"
           size="small"
           disableSearchAndFilter={true}
+          launchExternalUrl={true}
path: web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
user: login: ravicious
body: Nit: when setting boolean props, it's enough to do just `launchExternalUrl`.
created_at: 2025-03-17T16:40:14Z
author_association: MEMBER
diff_hunk: @@ -177,28 +185,50 @@ const LoginItemList = ({
   onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
   placeholder: string;
   width?: string;
+  disableSearchAndFilter: boolean;
+  launchExternalUrl: boolean;
path: web/packages/shared/components/MenuLogin/MenuLogin.tsx
user: login: ravicious
body: Is the only difference that `launchExternalUrl` introduces the fact that menu items have `target` set to `_blank`?

I feel like it might be better to add an optional property `target` to `LoginItem` that can be set to `'_blank'`. That way the decision can be made per individual item rather than for all items.

<details>
<summary>Patch</summary>

Copy then `pbpaste | git apply`

```diff
diff --git a/web/packages/shared/components/MenuLogin/MenuLogin.tsx b/web/packages/shared/components/MenuLogin/MenuLogin.tsx
index f112005c841..41bb873ea2e 100644
--- a/web/packages/shared/components/MenuLogin/MenuLogin.tsx
+++ b/web/packages/shared/components/MenuLogin/MenuLogin.tsx
@@ -49,7 +49,6 @@ export const MenuLogin = React.forwardRef<MenuLoginHandle, MenuLoginProps>(
       required = true,
       width,
       disableSearchAndFilter,
-      launchExternalUrl,
       style,
     } = props;
     const [filter, setFilter] = useState('');
@@ -159,7 +158,6 @@ export const MenuLogin = React.forwardRef<MenuLoginHandle, MenuLoginProps>(
             placeholder={placeholder}
             width={width}
             disableSearchAndFilter={disableSearchAndFilter}
-            launchExternalUrl={launchExternalUrl}
           />
         </Menu>
       </React.Fragment>
@@ -176,7 +174,6 @@ const LoginItemList = ({
   placeholder,
   width,
   disableSearchAndFilter,
-  launchExternalUrl,
 }: {
   getLoginItemsAttempt: Attempt<LoginItem[]>;
   items: LoginItem[];
@@ -186,14 +183,8 @@ const LoginItemList = ({
   placeholder: string;
   width?: string;
   disableSearchAndFilter: boolean;
-  launchExternalUrl: boolean;
 }) => {
-  const content = getLoginItemListContent(
-    items,
-    getLoginItemsAttempt,
-    onClick,
-    launchExternalUrl
-  );
+  const content = getLoginItemListContent(items, getLoginItemsAttempt, onClick);
 
   return (
     <Flex flexDirection="column" minWidth={width}>
@@ -237,8 +228,7 @@ const LoginItemList = ({
 function getLoginItemListContent(
   items: LoginItem[],
   getLoginItemsAttempt: Attempt<LoginItem[]>,
-  onClick: (e: React.MouseEvent<HTMLAnchorElement>, login: string) => void,
-  launchExternalUrl: boolean
+  onClick: (e: React.MouseEvent<HTMLAnchorElement>, login: string) => void
 ) {
   switch (getLoginItemsAttempt.status) {
     case '':
@@ -258,26 +248,7 @@ function getLoginItemListContent(
       return null;
     case 'success':
       return items.map((item, key) => {
-        const { login, url } = item;
-        if (launchExternalUrl) {
-          return (
-            <StyledMenuItem
-              key={key}
-              as="a"
-              px="2"
-              mx="2"
-              href={url}
-              target="_blank"
-              title={login ? login : url}
-              /* onClick only serves to close the menu item on click. */
-              onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
-                onClick(e, url);
-              }}
-            >
-              {login ? login : url}
-            </StyledMenuItem>
-          );
-        }
+        const { login, url, target } = item;
         return (
           <StyledMenuItem
             key={key}
@@ -288,6 +259,7 @@ function getLoginItemListContent(
             onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
               onClick(e, login);
             }}
+            {...(target ? { target } : {})}
           >
             {login}
           </StyledMenuItem>
diff --git a/web/packages/shared/components/MenuLogin/types.ts b/web/packages/shared/components/MenuLogin/types.ts
index fd756904552..2618a5c71f3 100644
--- a/web/packages/shared/components/MenuLogin/types.ts
+++ b/web/packages/shared/components/MenuLogin/types.ts
@@ -23,6 +23,7 @@ import { ButtonBorder } from 'design/Button';
 export type LoginItem = {
   url: string;
   login: string;
+  target?: '_blank';
 };
 
 // MenuInputType determines how the input present in the MenuLogin
@@ -53,8 +54,6 @@ export type MenuLoginProps = {
    * is shown if this value is set to true.
    */
   disableSearchAndFilter?: boolean;
-  /* launchExternalUrl specifies that the login URL points to an external URL. */
-  launchExternalUrl?: boolean;
   style?: CSSProperties;
 };
 
diff --git a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
index b9e7cad0bef..6e7cb14b875 100644
--- a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
+++ b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
@@ -62,7 +62,6 @@ export const MenuLoginWithActionMenu = ({
   size = 'medium',
   placeholder,
   disableSearchAndFilter,
-  launchExternalUrl,
 }: {
   buttonText: string;
   onSelect: (e: React.SyntheticEvent, login: string) => void;
@@ -72,7 +71,6 @@ export const MenuLoginWithActionMenu = ({
   size?: ButtonSize;
   placeholder?: string;
   disableSearchAndFilter?: boolean;
-  launchExternalUrl?: boolean;
 }) => {
   const moreButtonRef = useRef<HTMLButtonElement>(null);
   const [isOpen, setIsOpen] = useState(false);
@@ -96,7 +94,6 @@ export const MenuLoginWithActionMenu = ({
         style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
         buttonText={buttonText}
         disableSearchAndFilter={disableSearchAndFilter}
-        launchExternalUrl={launchExternalUrl}
         placeholder={placeholder}
       />
       <ButtonBorder
diff --git a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
index 9b30d015217..30a8853a3a8 100644
--- a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
+++ b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
@@ -439,7 +439,6 @@ function makeSamlAppLoginWithMenuButton(
           buttonText="Log In"
           size="small"
           disableSearchAndFilter={true}
-          launchExternalUrl={true}
           placeholder="Select URL to log in"
         >
           {SamlActionMenu}
@@ -504,7 +503,6 @@ function makeSamlAppLoginButton(
           style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
           buttonText="Log In"
           disableSearchAndFilter={true}
-          launchExternalUrl={true}
           placeholder="Select URL to log in"
         />
       );
@@ -535,6 +533,7 @@ const makeSamlAppLoginOptions = (
     return {
       login: u.friendlyName ? u.friendlyName : u.url,
       url: u.url,
+      target: '_blank',
     };
   });
 };

```

</details>
created_at: 2025-03-17T16:54:42Z
author_association: MEMBER
diff_hunk: @@ -254,23 +246,15 @@ const AppLaunch = ({ app }: AppLaunchProps) => {
           >
             Delete
           </MenuItem>
-        </ButtonWithMenu>
+        </>
       );
-    } else {
-      return (
-        <ButtonBorder
-          as="a"
-          width="123px"
-          size="small"
-          target="_blank"
-          href={samlAppSsoUrl}
-          rel="noreferrer"
-          textTransform="none"
-          title="Log in to SAML application"
-        >
-          Log In
-        </ButtonBorder>
+      return makeSamlAppLoginWithMenuButton(
+        samlAppSsoUrl,
+        samlAppLaunchUrls,
+        samlAppActionMenu
       );
+    } else {
+      return makeSamlAppLoginButton(samlAppSsoUrl, samlAppLaunchUrls);
path: web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
user: login: ravicious
body: After your branch was created, I added a story for `ResourceActionButton` on master. Could you make sure that both variants of the SAML button are shown there?
created_at: 2025-03-17T16:56:40Z
author_association: MEMBER
diff_hunk: @@ -48,6 +48,14 @@ export type MenuLoginProps = {
   width?: string;
   ButtonComponent?: ComponentType<ComponentPropsWithRef<typeof ButtonBorder>>;
   buttonText?: string;
+  /*
+   * Text label, instead of default search and filter input,
+   * is shown if this value is set to true.
+   */
path: web/packages/shared/components/MenuLogin/types.ts
user: login: ravicious
body: For a comment to be recognized as a JSDoc, it must start with `/**`.
created_at: 2025-03-17T17:00:15Z
author_association: MEMBER
diff_hunk: @@ -48,6 +48,14 @@ export type MenuLoginProps = {
   width?: string;
   ButtonComponent?: ComponentType<ComponentPropsWithRef<typeof ButtonBorder>>;
   buttonText?: string;
+  /*
+   * Text label, instead of default search and filter input,
+   * is shown if this value is set to true.
+   */
+  disableSearchAndFilter?: boolean;
path: web/packages/shared/components/MenuLogin/types.ts
user: login: ravicious
body: From what I see, `MenuLogin` doesn't seem to use the term "search" anywhere, other than as the default placeholder when `inputType` is set to filter.

But disregarding the name of this prop, I think it'd nicer to introduce a third option to `MenuInputType` called `NONE` or something. Otherwise it's possible to pass both `disableSearchAndFilter={true}` and `inputType={MenuInputType.FILTER}`, but those two options together don't make much sense.
created_at: 2025-03-17T17:05:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,138 @@
+/**
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { ReactElement, useRef, useState } from 'react';
+
+import { ButtonBorder, Flex, Menu, MenuItem } from 'design';
+import { ButtonSize } from 'design/Button';
+import * as icons from 'design/Icon';
+import {
+  LoginItem,
+  MenuInputType,
+  MenuLogin,
+} from 'shared/components/MenuLogin';
+
+/**
+ * Displays a menu button with additional action menu attached to the right of it.
+ * <MenuLoginWithActionMenu> expects two children, a LoginItem for <MenuLogin> and <MenuItem>
+ * for action memnu.
+ *
+ * @param buttonText - button text for main menu button.
+ * @param onSelect - handles select or click in main menu items.
+ * @param getLoginItems - fetches login items.
+ * @param children - action menu items.
+ * @param placeholder - text for action menu search box or static label.
+ * @param size - button icon size.
+ * @param width - button width.
+ * @param disableSearchAndFilter - instructs underlying MenuLogin component to display static
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
user: login: ravicious
body: I don't think these JSDoc param tags are correct, as AFAIR `@param` when used like this is for positional arguments and `MenuLoginWithActionMenu` has only one positional argument.

Even if they were updated to the correct form (see [Parameters with properties](https://jsdoc.app/tags-param#parameters-with-properties)), I'm not sure if most editor integrations are able to correctly parse them. Usually I use an individual JSDoc comment for each prop instead:

https://github.com/gravitational/teleport/blob/3d22adc854c3038da5bfd66e71dee2af66b2d20e/web/packages/teleterm/src/ui/components/OfflineGateway.tsx#L29-L51
created_at: 2025-03-17T17:11:44Z
author_association: MEMBER
diff_hunk: @@ -199,17 +202,27 @@ func makePermissionSets(src []*types.IdentityCenterPermissionSet) []IdentityCent
 // Web UI. Thus, this field is currently not available in the Connect App type.
 func MakeAppTypeFromSAMLApp(app types.SAMLIdPServiceProvider, c MakeAppsConfig) App {
 	labels := ui.MakeLabelsWithoutInternalPrefixes(app.GetAllLabels())
+	uiLaunchURLs := func(in []string) []SAMLAppLaunchURL {
+		out := make([]SAMLAppLaunchURL, 0, len(in))
+		for _, u := range in {
+			out = append(out, SAMLAppLaunchURL{
+				URL: u,
+			})
path: lib/web/ui/app.go
user: login: flyinghermit
body: `FriendlyName` is not implemented in the backend yet. I only realized I should have supported that in my first attempt. But I defined that in the web API response just so it self-aligns the response shape if I ever update the backend to support it. 
created_at: 2025-03-18T00:18:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -177,28 +185,50 @@ const LoginItemList = ({
   onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
   placeholder: string;
   width?: string;
+  disableSearchAndFilter: boolean;
+  launchExternalUrl: boolean;
path: web/packages/shared/components/MenuLogin/MenuLogin.tsx
user: login: flyinghermit
body: The existing item component also uses `<NavLink>`assuming the url points to an internal url which is true for node, app logins.  So the value of `launchExternalUrl` is used to apply both `target: _blank` and `href` attribute. 
Though I liked passing this value from within the `LoginItem` much better. I am using that field as `isExternalUrl` in https://github.com/gravitational/teleport/pull/52877/commits/d051243da09801f55cc8fc0970b1eb9ceccdd31a. lmk what you think. 
created_at: 2025-03-18T03:14:51Z
author_association: CONTRIBUTOR
path: web/packages/shared/components/MenuLogin/MenuLogin.tsx
user: login: flyinghermit
body: Good catch. 
I only tested this with the live login menu in the unified resource, which passes `url` value in login item, causing `NavLink` component to render. That does the left-alignment menu item by default. 
![Screenshot 2025-03-17 at 8 16 36 PM](https://github.com/user-attachments/assets/b376580c-abe4-4c90-bfb0-128641d01f96). The `href` tag which is added in the this PR for the login item link does that trick too. 

This PR now adds `text-align: left` property which aligns to left by default. Thanks!  https://github.com/gravitational/teleport/pull/52877/commits/4fd09c185a432c4051b82a171206afceb7654575
created_at: 2025-03-18T03:22:55Z
author_association: CONTRIBUTOR
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
user: login: flyinghermit
body: `MenuLoginWithActionMenu ` supports `size` props and the default is "medium". `MenuLogin` hardcodes `size="small` hence the difference in the size. 

I've updated `MenuLoginWithActionMenu` story to use `size=small` to avoid this confusion https://github.com/gravitational/teleport/pull/52877/commits/4fd09c185a432c4051b82a171206afceb7654575
created_at: 2025-03-18T03:24:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,138 @@
+/**
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { ReactElement, useRef, useState } from 'react';
+
+import { ButtonBorder, Flex, Menu, MenuItem } from 'design';
+import { ButtonSize } from 'design/Button';
+import * as icons from 'design/Icon';
+import {
+  LoginItem,
+  MenuInputType,
+  MenuLogin,
+} from 'shared/components/MenuLogin';
+
+/**
+ * Displays a menu button with additional action menu attached to the right of it.
+ * <MenuLoginWithActionMenu> expects two children, a LoginItem for <MenuLogin> and <MenuItem>
+ * for action memnu.
+ *
+ * @param buttonText - button text for main menu button.
+ * @param onSelect - handles select or click in main menu items.
+ * @param getLoginItems - fetches login items.
+ * @param children - action menu items.
+ * @param placeholder - text for action menu search box or static label.
+ * @param size - button icon size.
+ * @param width - button width.
+ * @param disableSearchAndFilter - instructs underlying MenuLogin component to display static
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
user: login: flyinghermit
body: Agree https://github.com/gravitational/teleport/pull/52877/commits/59bf9a2547973be133789597214d6ddc08ac496c
created_at: 2025-03-18T03:33:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,6 +48,14 @@ export type MenuLoginProps = {
   width?: string;
   ButtonComponent?: ComponentType<ComponentPropsWithRef<typeof ButtonBorder>>;
   buttonText?: string;
+  /*
+   * Text label, instead of default search and filter input,
+   * is shown if this value is set to true.
+   */
+  disableSearchAndFilter?: boolean;
path: web/packages/shared/components/MenuLogin/types.ts
user: login: flyinghermit
body: Using `MenuInputType.NONE` is much much better. Thanks https://github.com/gravitational/teleport/pull/52877/commits/a482c40b896bb187e784220fc0366060eaad2dee
created_at: 2025-03-18T03:57:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -254,23 +246,15 @@ const AppLaunch = ({ app }: AppLaunchProps) => {
           >
             Delete
           </MenuItem>
-        </ButtonWithMenu>
+        </>
       );
-    } else {
-      return (
-        <ButtonBorder
-          as="a"
-          width="123px"
-          size="small"
-          target="_blank"
-          href={samlAppSsoUrl}
-          rel="noreferrer"
-          textTransform="none"
-          title="Log in to SAML application"
-        >
-          Log In
-        </ButtonBorder>
+      return makeSamlAppLoginWithMenuButton(
+        samlAppSsoUrl,
+        samlAppLaunchUrls,
+        samlAppActionMenu
       );
+    } else {
+      return makeSamlAppLoginButton(samlAppSsoUrl, samlAppLaunchUrls);
path: web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
user: login: flyinghermit
body: Done https://github.com/gravitational/teleport/pull/52877/commits/8f91992adb594bb4a0fc9863503ba8ea5216e9a4
created_at: 2025-03-18T13:46:41Z
author_association: CONTRIBUTOR
path: web/packages/shared/components/MenuLogin/MenuLogin.tsx
user: login: ravicious
body: But how did we end up needing to add `text-align: left;` in the first place? To me it indicates that we might have added some extra changes that might not be necessary, so we should aim to trim them down rather than adding more changes. 😏
created_at: 2025-03-18T16:15:07Z
author_association: MEMBER
diff_hunk: @@ -177,28 +185,50 @@ const LoginItemList = ({
   onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
   placeholder: string;
   width?: string;
+  disableSearchAndFilter: boolean;
+  launchExternalUrl: boolean;
path: web/packages/shared/components/MenuLogin/MenuLogin.tsx
user: login: ravicious
body: That makes sense, thanks. Let's just please document here that if `isExternalUrl` of a menu item is true, `onSelect` is called with the `url` property of a menu item, not `login`.

It'd be nice to have tests for it as well. Otherwise it might be a tad to easy to change this behavior by mistake, since both `url` and `login` are strings, so TypeScript won't help us here.

https://github.com/gravitational/teleport/blob/bcf1625f9a3c4dfb46125aec3e10e384e3a87516/web/packages/shared/components/MenuLogin/types.ts#L40
created_at: 2025-03-18T16:28:35Z
author_association: MEMBER
path: web/packages/shared/components/MenuLogin/MenuLogin.tsx
user: login: ravicious
body: Oh, I see, it's because of `display: inline-block`, which is required to make `text-overflow: ellipsis;` work.
created_at: 2025-03-18T17:38:48Z
author_association: MEMBER
diff_hunk: @@ -259,6 +298,12 @@ const StyledMenuItem = styled(MenuItem)(
   font-size: 12px;
   border-bottom: 1px solid ${theme.colors.spotBackground[0]};
   min-height: 32px;
+  display: inline-block;
+  text-align: left;
+  max-width: 450px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
path: web/packages/shared/components/MenuLogin/MenuLogin.tsx
user: login: ravicious
body: The menu theoretically looks good if we completely remove all of these styles, so let's add a comment explaining why they're needed.
created_at: 2025-03-18T17:39:34Z
author_association: MEMBER
diff_hunk: @@ -177,28 +181,44 @@ const LoginItemList = ({
   onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
   placeholder: string;
   width?: string;
+  inputType?: MenuInputType;
 }) => {
   const content = getLoginItemListContent(items, getLoginItemsAttempt, onClick);
 
   return (
     <Flex flexDirection="column" minWidth={width}>
-      <Input
-        p="2"
-        m="2"
-        // this prevents safari from adding the autofill options which would cover the available logins and make it
-        // impossible to select. "But why would it do that? this isn't a username or password field?".
-        // Safari includes parsed words in the placeholder as well to determine if that autofill should show.
-        // Since our placeholder has the word "login" in it, it thinks its a login form.
-        // https://github.com/gravitational/teleport/pull/31600
-        // https://stackoverflow.com/questions/22661977/disabling-safari-autofill-on-usernames-and-passwords
-        name="notsearch_password"
-        onKeyPress={onKeyPress}
-        onChange={onChange}
-        type="text"
-        autoFocus
-        placeholder={placeholder}
-        autoComplete="off"
-      />
+      {inputType === MenuInputType.NONE ? (
+        /* css and margin value matched with AWS Launch button <RoleItemList> */
+        <Text
+          px="2"
+          mb={2}
+          typography="body3"
+          css={`
+            color: ${props => props.theme.colors.text.main};
+            background: ${props => props.theme.colors.spotBackground[2]};
+          `}
path: web/packages/shared/components/MenuLogin/MenuLogin.tsx
user: login: ravicious
body: ```suggestion
          color="text.main"
          backgroundColor="spotBackground.2"
```
created_at: 2025-03-18T17:41:17Z
author_association: MEMBER
diff_hunk: @@ -425,3 +409,130 @@ const makeDesktopLoginOptions = (
     };
   });
 };
+
+/**
+ * makeSamlAppLoginWithMenuButton returns login button for SAML apps.
+ * If launchUrls is not empty and its length is greater than one,
+ * MenuLoginWithActionMenu button is returned.
+ * if launchUrls is not empty but its length is exactly one, ButtonWithMenu
+ * is returned with the href value configured with the launchUrls[0] value.
+ * If launchUrls is empty, ButtonWithMenu is returned with the href value
+ * configured with samlAppSsoUrl.
+ * @param samlAppSsoUrl - ACS URL (also known as SSO endpoint) value for SAML app.
+ * @param launchUrls - custom service provider endpoints that will be used to initiate
+ *                     authentication, instead of the ACS URL.
+ * @param SamlActionMenu - SAML app edit and delete action menu items.
+ */
+function makeSamlAppLoginWithMenuButton(
+  samlAppSsoUrl: string,
+  launchUrls: SamlAppLaunchUrl[],
+  SamlActionMenu: JSX.Element
+) {
+  let ssoUrl = samlAppSsoUrl;
+  if (launchUrls) {
+    if (launchUrls.length > 1) {
+      return (
+        <MenuLoginWithActionMenu
+          getLoginItems={() => makeSamlAppLoginOptions(launchUrls)}
+          width="100px"
+          onSelect={() => {}} // login item rendered as <a> link for saml apps.
+          buttonText="Log In"
+          size="small"
+          inputType={MenuInputType.NONE}
+          placeholder="Select URL to log in"
+        >
+          {SamlActionMenu}
+        </MenuLoginWithActionMenu>
+      );
+    }
+    ssoUrl = launchUrls[0].url;
+  }
+
+  return (
+    <ButtonWithMenu
+      text="Log In"
+      width="100px"
+      size="small"
+      target="_blank"
+      href={ssoUrl}
+      rel="noreferrer"
+      textTransform="none"
+      forwardedAs="a"
+      title="Log in to SAML application"
+    >
+      {SamlActionMenu}
+    </ButtonWithMenu>
+  );
+}
+
+/**
+ * makeSamlAppLoginButton returns login button for SAML apps.
+ * If launchUrls is not empty and its length is greater than one,
+ * MenuLogin button is returned.
+ * if launchUrls is not empty but its length is exactly one, ButtonBorder
+ * is returned with the href value configured with the launchUrls[0] value.
+ * If launchUrls is empty, ButtonBorder is returned with the href value
+ * configured with samlAppSsoUrl.
+ * @param samlAppSsoUrl - ACS URL (also known as SSO endpoint) value for SAML app.
+ * @param launchUrls - custom service provider endpoints that will be used to initiate
+ *                     authentication, instead of the ACS URL.
+ */
+function makeSamlAppLoginButton(
+  samlAppSsoUrl: string,
+  launchUrls: SamlAppLaunchUrl[]
+) {
+  let ssoUrl = samlAppSsoUrl;
+  if (launchUrls) {
+    if (launchUrls.length > 1) {
+      return (
+        <MenuLogin
+          width="100px"
+          inputType={MenuInputType.NONE}
+          onSelect={() => {}} // login item rendered as <a> link for saml apps.
+          textTransform="none"
+          alignButtonWidthToMenu
+          getLoginItems={() => makeSamlAppLoginOptions(launchUrls)}
+          transformOrigin={{
+            vertical: 'top',
+            horizontal: 'right',
+          }}
+          anchorOrigin={{
+            vertical: 'bottom',
+            horizontal: 'right',
+          }}
+          style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
path: web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
user: login: ravicious
body: Removing the borders here seems unnecessary, no? There's no action menu to the right of `MenuLogin` here.
created_at: 2025-03-18T17:46:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,93 @@
+/**
+ * Teleport
+ * Copyright (C) 2023  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { MenuItem } from 'design';
+import { fireEvent, render, screen, waitFor } from 'design/utils/testing';
+import { MenuInputType } from 'shared/components/MenuLogin';
+
+import { MenuLoginWithActionMenu } from './MenuLoginWithActionMenu';
+
+test('clicking on primary menu and secondary menu opens respective menu items', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [{ url: '', login: 'alice' }]}
+      onSelect={() => null}
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  await waitFor(() => {
+    expect(screen.getByText('alice')).toBeInTheDocument();
+  });
+
+  fireEvent.click(screen.getByTitle('Open menu'));
+  await waitFor(() => {
+    expect(screen.getByText('Menu item')).toBeInTheDocument();
+  });
+});
+
+test('search and filter input field should be visible by default', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [
+        { url: '', login: 'alice' },
+        { url: '', login: 'bob' },
+      ]}
+      onSelect={() => null}
+      placeholder="search me"
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  await waitFor(() => {
+    expect(screen.getByText('alice')).toBeInTheDocument();
+  });
+
+  expect(screen.getByPlaceholderText('search me')).toBeInTheDocument();
+});
+
+test('disableSearchAndFilter should show static menu item laabel', async () => {
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.test.tsx
user: login: ravicious
body: ```suggestion
test('MenuInputType.NONE should show static menu item label', async () => {
```
created_at: 2025-03-18T17:47:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,93 @@
+/**
+ * Teleport
+ * Copyright (C) 2023  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { MenuItem } from 'design';
+import { fireEvent, render, screen, waitFor } from 'design/utils/testing';
+import { MenuInputType } from 'shared/components/MenuLogin';
+
+import { MenuLoginWithActionMenu } from './MenuLoginWithActionMenu';
+
+test('clicking on primary menu and secondary menu opens respective menu items', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [{ url: '', login: 'alice' }]}
+      onSelect={() => null}
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  await waitFor(() => {
+    expect(screen.getByText('alice')).toBeInTheDocument();
+  });
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.test.tsx
user: login: ravicious
body: Every use of `waitFor` followed by `screen.getByText` can be replaced with `await screen.findByText`. `screen.findByText` behaves same as `waitFor` underneath.

```suggestion
  expect(await screen.findByText('alice')).toBeInTheDocument();
```
created_at: 2025-03-18T17:49:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,93 @@
+/**
+ * Teleport
+ * Copyright (C) 2023  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { MenuItem } from 'design';
+import { fireEvent, render, screen, waitFor } from 'design/utils/testing';
+import { MenuInputType } from 'shared/components/MenuLogin';
+
+import { MenuLoginWithActionMenu } from './MenuLoginWithActionMenu';
+
+test('clicking on primary menu and secondary menu opens respective menu items', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [{ url: '', login: 'alice' }]}
+      onSelect={() => null}
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  await waitFor(() => {
+    expect(screen.getByText('alice')).toBeInTheDocument();
+  });
+
+  fireEvent.click(screen.getByTitle('Open menu'));
+  await waitFor(() => {
+    expect(screen.getByText('Menu item')).toBeInTheDocument();
+  });
+});
+
+test('search and filter input field should be visible by default', async () => {
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.test.tsx
user: login: ravicious
body: ```suggestion
test('filter input field should be visible by default', async () => {
```
created_at: 2025-03-18T17:49:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,93 @@
+/**
+ * Teleport
+ * Copyright (C) 2023  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { MenuItem } from 'design';
+import { fireEvent, render, screen, waitFor } from 'design/utils/testing';
+import { MenuInputType } from 'shared/components/MenuLogin';
+
+import { MenuLoginWithActionMenu } from './MenuLoginWithActionMenu';
+
+test('clicking on primary menu and secondary menu opens respective menu items', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [{ url: '', login: 'alice' }]}
+      onSelect={() => null}
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  await waitFor(() => {
+    expect(screen.getByText('alice')).toBeInTheDocument();
+  });
+
+  fireEvent.click(screen.getByTitle('Open menu'));
+  await waitFor(() => {
+    expect(screen.getByText('Menu item')).toBeInTheDocument();
+  });
+});
+
+test('search and filter input field should be visible by default', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [
+        { url: '', login: 'alice' },
+        { url: '', login: 'bob' },
+      ]}
+      onSelect={() => null}
+      placeholder="search me"
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  await waitFor(() => {
+    expect(screen.getByText('alice')).toBeInTheDocument();
+  });
+
+  expect(screen.getByPlaceholderText('search me')).toBeInTheDocument();
+});
+
+test('disableSearchAndFilter should show static menu item laabel', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [
+        { url: '', login: 'alice' },
+        { url: '', login: 'bob' },
+      ]}
+      onSelect={() => null}
+      placeholder="search me"
+      inputType={MenuInputType.NONE}
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  await waitFor(() => {
+    expect(screen.getByText('alice')).toBeInTheDocument();
+  });
+
+  expect(screen.getByText('search me')).toBeInTheDocument();
+  expect(screen.queryByPlaceholderText('search me')).not.toBeInTheDocument();
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.test.tsx
user: login: ravicious
body: ```suggestion
  // Verify that placeholder is used as a text label above menu and not as an actual placeholder.
  expect(screen.getByText('search me')).toBeInTheDocument();
  expect(screen.queryByPlaceholderText('search me')).not.toBeInTheDocument();
```
created_at: 2025-03-18T17:51:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,130 @@
+/**
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { ReactElement, useRef, useState } from 'react';
+
+import { ButtonBorder, Flex, Menu, MenuItem } from 'design';
+import { ButtonSize } from 'design/Button';
+import * as icons from 'design/Icon';
+import {
+  LoginItem,
+  MenuInputType,
+  MenuLogin,
+} from 'shared/components/MenuLogin';
+
+/**
+ * Displays a menu button with additional action menu attached to the right of it.
+ * <MenuLoginWithActionMenu> expects two children, a LoginItem for <MenuLogin> and <MenuItem>
+ * for action memnu.
+ *
+ * @example
+ * <MenuLoginWithActionMenu
+ *   buttonText="Log In"
+ *   size="medium"
+ *   onSelect={() => alert('item selected')}
+ *   getLoginItems= {() => LoginItem[]}
+ * >
+ *   <MenuItem>Foo</MenuItem>
+ *   <MenuItem>Bar</MenuItem>
+ * </MenuLoginWithActionMenu>
+ */
+export const MenuLoginWithActionMenu = ({
+  buttonText,
+  onSelect,
+  getLoginItems,
+  children,
+  width,
+  size = 'medium',
+  placeholder,
+  inputType,
+}: {
+  /** button text for main menu button. */
+  buttonText: string;
+  /** handles select or click in main menu items. */
+  onSelect: (e: React.SyntheticEvent, login: string) => void;
+  /** fetches login items. */
+  getLoginItems: () => LoginItem[] | Promise<LoginItem[]>;
+  /** action menu items. */
+  children: MenuItemComponent | MenuItemComponent[];
+  width?: string;
+  size?: ButtonSize;
+  /** text for action menu search box or static label.  */
+  placeholder?: string;
+  /** input type for menu item filter input. */
+  inputType?: MenuInputType;
+}) => {
+  const moreButtonRef = useRef<HTMLButtonElement>(null);
+  const [isOpen, setIsOpen] = useState(false);
+  return (
+    <Flex>
+      <MenuLogin
+        width={width}
+        inputType={inputType}
+        onSelect={onSelect}
+        textTransform="none"
+        alignButtonWidthToMenu
+        getLoginItems={getLoginItems}
+        transformOrigin={{
+          vertical: 'top',
+          horizontal: 'right',
+        }}
+        anchorOrigin={{
+          vertical: 'bottom',
+          horizontal: 'right',
+        }}
+        style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
+        buttonText={buttonText}
+        placeholder={placeholder}
+      />
+      <ButtonBorder
+        setRef={moreButtonRef}
+        px={1}
+        size={size}
+        onClick={() => setIsOpen(true)}
+        /* css value matches with ButtonWithMenu component */
+        css={`
+          border-left: none;
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        `}
+        title="Open menu"
+      >
+        <icons.MoreVert size={size} color="text.slightlyMuted" />
+      </ButtonBorder>
+      <Menu
+        anchorEl={moreButtonRef.current}
+        open={isOpen}
+        onClose={() => setIsOpen(false)}
+        getContentAnchorEl={null}
+        anchorOrigin={{
+          vertical: 'bottom',
+          horizontal: 'right',
+        }}
+        transformOrigin={{
+          vertical: 'top',
+          horizontal: 'right',
+        }}
+        menuListProps={{ onClick: () => setIsOpen(false) }}
+      >
+        {children}
+      </Menu>
+    </Flex>
+  );
+};
+
+type MenuItemComponent = ReactElement<typeof MenuItem>;
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
user: login: ravicious
body: Let's copy the same caveat comment from `ButtonWithMenu` here. I read it again now and I'm not sure if it's still true or not. 😂 But we can inspect that some other day.
created_at: 2025-03-18T17:53:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,99 @@
+/**
+ * Teleport
+ * Copyright (C) 2025  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { MemoryRouter } from 'react-router';
+
+import { fireEvent, render, screen, waitFor } from 'design/utils/testing';
+
+import { SamlAppActionProvider } from 'teleport/SamlApplications/useSamlAppActions';
+import { App } from 'teleport/services/apps';
+
+import { ResourceActionButton } from './ResourceActionButton';
+
+test('saml app: empty launch url should display <a> link with href value pointed to samlAppSsoUrl', () => {
+  render(
+    <SamlAppActionProvider>
+      <ResourceActionButton resource={resource} />
+    </SamlAppActionProvider>
+  );
+
+  expect(screen.getByRole('link', { name: /Log In/i })).toHaveAttribute(
+    'href',
+    resource.samlAppSsoUrl
+  );
+});
+
+test('saml app: launch url with one item should display <a> link with href value pointed to first launch url item', () => {
path: web/packages/teleport/src/UnifiedResources/ResourceActionButton.test.tsx
user: login: ravicious
body: I saw the code for this and thought that it might be easy to break that by mistake, it's good that you added this test.
created_at: 2025-03-18T17:55:08Z
author_association: MEMBER
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
user: login: ravicious
body: For now having `MenuLoginWithActionMenu` is fine. Long-term or if we get around to adding another component like this, it might be better to look into refactoring the existing buttons and menus so that we can mix and match them however we like instead having to create another `FooWithBar` component.

But this might never come to be tbh, we don't have _that_ many things to mix and match. ;f
created_at: 2025-03-18T17:57:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,93 @@
+/**
+ * Teleport
+ * Copyright (C) 2023  Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { MenuItem } from 'design';
+import { fireEvent, render, screen, waitFor } from 'design/utils/testing';
+import { MenuInputType } from 'shared/components/MenuLogin';
+
+import { MenuLoginWithActionMenu } from './MenuLoginWithActionMenu';
+
+test('clicking on primary menu and secondary menu opens respective menu items', async () => {
+  render(
+    <MenuLoginWithActionMenu
+      buttonText="Click Me"
+      getLoginItems={() => [{ url: '', login: 'alice' }]}
+      onSelect={() => null}
+    >
+      <MenuItem>Menu item</MenuItem>
+    </MenuLoginWithActionMenu>
+  );
+
+  fireEvent.click(screen.getByText('Click Me'));
+  await waitFor(() => {
+    expect(screen.getByText('alice')).toBeInTheDocument();
+  });
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.test.tsx
user: login: flyinghermit
body: Oh nice I didn't knew it. Thanks!
created_at: 2025-03-19T23:41:55Z
author_association: CONTRIBUTOR
path: web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx
user: login: flyinghermit
body: Yeah. I actually started with modifying `ButtonWithMenu` to accept button as props. But then I found adding a separate `MenuLoginWithActionMenu` looked much cleaner. But now its done, I am not sure about either of those options. Let's see if we ever require to introduce another type of `<xWithMenu>` component that is not support by either `ButtonWithMenu` or `MenuLoginWithActionMenu`.
created_at: 2025-03-20T00:13:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -425,3 +409,130 @@ const makeDesktopLoginOptions = (
     };
   });
 };
+
+/**
+ * makeSamlAppLoginWithMenuButton returns login button for SAML apps.
+ * If launchUrls is not empty and its length is greater than one,
+ * MenuLoginWithActionMenu button is returned.
+ * if launchUrls is not empty but its length is exactly one, ButtonWithMenu
+ * is returned with the href value configured with the launchUrls[0] value.
+ * If launchUrls is empty, ButtonWithMenu is returned with the href value
+ * configured with samlAppSsoUrl.
+ * @param samlAppSsoUrl - ACS URL (also known as SSO endpoint) value for SAML app.
+ * @param launchUrls - custom service provider endpoints that will be used to initiate
+ *                     authentication, instead of the ACS URL.
+ * @param SamlActionMenu - SAML app edit and delete action menu items.
+ */
+function makeSamlAppLoginWithMenuButton(
+  samlAppSsoUrl: string,
+  launchUrls: SamlAppLaunchUrl[],
+  SamlActionMenu: JSX.Element
+) {
+  let ssoUrl = samlAppSsoUrl;
+  if (launchUrls) {
+    if (launchUrls.length > 1) {
+      return (
+        <MenuLoginWithActionMenu
+          getLoginItems={() => makeSamlAppLoginOptions(launchUrls)}
+          width="100px"
+          onSelect={() => {}} // login item rendered as <a> link for saml apps.
+          buttonText="Log In"
+          size="small"
+          inputType={MenuInputType.NONE}
+          placeholder="Select URL to log in"
+        >
+          {SamlActionMenu}
+        </MenuLoginWithActionMenu>
+      );
+    }
+    ssoUrl = launchUrls[0].url;
+  }
+
+  return (
+    <ButtonWithMenu
+      text="Log In"
+      width="100px"
+      size="small"
+      target="_blank"
+      href={ssoUrl}
+      rel="noreferrer"
+      textTransform="none"
+      forwardedAs="a"
+      title="Log in to SAML application"
+    >
+      {SamlActionMenu}
+    </ButtonWithMenu>
+  );
+}
+
+/**
+ * makeSamlAppLoginButton returns login button for SAML apps.
+ * If launchUrls is not empty and its length is greater than one,
+ * MenuLogin button is returned.
+ * if launchUrls is not empty but its length is exactly one, ButtonBorder
+ * is returned with the href value configured with the launchUrls[0] value.
+ * If launchUrls is empty, ButtonBorder is returned with the href value
+ * configured with samlAppSsoUrl.
+ * @param samlAppSsoUrl - ACS URL (also known as SSO endpoint) value for SAML app.
+ * @param launchUrls - custom service provider endpoints that will be used to initiate
+ *                     authentication, instead of the ACS URL.
+ */
+function makeSamlAppLoginButton(
+  samlAppSsoUrl: string,
+  launchUrls: SamlAppLaunchUrl[]
+) {
+  let ssoUrl = samlAppSsoUrl;
+  if (launchUrls) {
+    if (launchUrls.length > 1) {
+      return (
+        <MenuLogin
+          width="100px"
+          inputType={MenuInputType.NONE}
+          onSelect={() => {}} // login item rendered as <a> link for saml apps.
+          textTransform="none"
+          alignButtonWidthToMenu
+          getLoginItems={() => makeSamlAppLoginOptions(launchUrls)}
+          transformOrigin={{
+            vertical: 'top',
+            horizontal: 'right',
+          }}
+          anchorOrigin={{
+            vertical: 'bottom',
+            horizontal: 'right',
+          }}
+          style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
path: web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx
user: login: flyinghermit
body: You are right. removed. 
created_at: 2025-03-20T00:29:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -177,28 +185,50 @@ const LoginItemList = ({
   onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
   placeholder: string;
   width?: string;
+  disableSearchAndFilter: boolean;
+  launchExternalUrl: boolean;
path: web/packages/shared/components/MenuLogin/MenuLogin.tsx
user: login: flyinghermit
body: So when `isExternalUrl` is true, the button is rendered with `<a href={url} />` tag. In that case the `onSelect` is rather meaningless as the `<a>` tag handles the onClick by default. I have now explained this in a comment but I left out the test part for now. I can do that but I see it artificial for now as in my current implementation, for SAML apps, I am passing empty function for `onSelect` for exactly the same reason. 

And I didn't wanted to make it optional cause the underlying `<MenuLogin>` expects it as of now. 
created_at: 2025-03-20T01:29:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: flyinghermit
created_at: 2025-03-08T01:03:01Z
body: both selectable and direct link login button displayed below:
![Screenshot 2025-03-07 at 7 33 49 PM](https://github.com/user-attachments/assets/efb3de04-cda7-4933-be8d-b76c566cf8b9)

author_association: CONTRIBUTOR
user: login: backport-bot-workflows[bot]
created_at: 2025-03-20T19:09:44Z
body: 
@flyinghermit See the table below for backport results.

| Branch | Result |
|--------|--------|
| branch/v17 | [Create PR](https://github.com/gravitational/teleport/compare/branch/v17...bot/backport-52877-branch/v17?body=Backport+%2352877+to+branch%2Fv17&expand=1&labels=no-changelog&title=%5Bv17%5D+Web+UI%3A+support+SAML+app+launch+URLs) |

author_association: CONTRIBUTOR