diff --git a/lib/teleterm/daemon/daemon.go b/lib/teleterm/daemon/daemon.go
index c826468ca22da..f0b44f7bcb14c 100644
--- a/lib/teleterm/daemon/daemon.go
+++ b/lib/teleterm/daemon/daemon.go
@@ -114,11 +114,6 @@ func (s *Service) relogin(ctx context.Context, req *api.ReloginRequest) error {
}
defer s.reloginMu.Unlock()
- if err := s.importantModalSemaphore.Acquire(ctx); err != nil {
- return trace.Wrap(err)
- }
- defer s.importantModalSemaphore.Release()
-
const reloginUserTimeout = time.Minute
timeoutCtx, cancelTshdEventsCtx := context.WithTimeout(ctx, reloginUserTimeout)
defer cancelTshdEventsCtx()
@@ -892,7 +887,7 @@ func (s *Service) UpdateAndDialTshdEventsServerAddress(serverAddress string) err
client := api.NewTshdEventsServiceClient(conn)
s.tshdEventsClient = client
- s.importantModalSemaphore = newWaitSemaphore(maxConcurrentImportantModals, imporantModalWaitDuraiton)
+ s.headlessAuthSemaphore = newWaitSemaphore(maxConcurrentImportantModals, imporantModalWaitDuraiton)
return nil
}
@@ -1191,20 +1186,32 @@ type Service struct {
gateways map[string]gateway.Gateway
// tshdEventsClient is a client to send events to the Electron App.
tshdEventsClient api.TshdEventsServiceClient
- // The Electron App can only display one important Modal at a time. tshd events
- // that trigger an important modal (relogin, headless login) should use this
- // lock to ensure it doesn't overwrite existing tshd-initiated important modals.
- //
- // We use a semaphore instead of a mutex in order to cancel important modals that
- // are no longer relevant before acquisition.
+
+ // The Electron App can display multiple important modals by showing the latest one and hiding the others.
+ // However, we should be careful with it, and generally try to limit the number of prompts on the tshd side,
+ // to avoid flooding the app.
+ // Multiple prompts of the same type may also conflict with each other. This is currently possible with
+ // MFA prompts (see the mfaMu comment for details).
+ // Generally, only one prompt of each type (e.g., re-login, MFA) should be allowed at the same time.
//
- // We use a waitSemaphore in order to make sure there is a clear transition between modals.
- importantModalSemaphore *waitSemaphore
+ // But why do we allow multiple important modals at all? It is necessary in specific scenarios where one
+ // modal action requires completing in another. Currently, there are two cases:
+ // 1. A hardware key prompt may appear during a re-login process.
+ // 2. An MFA prompt may appear during a re-login process.
+
+ // We use a waitSemaphore to make sure there is a clear transition between modals.
+ // We allow a single headless auth prompt at a time.
+ headlessAuthSemaphore *waitSemaphore
+ // mfaMu prevents concurrent MFA prompts. These can happen when using VNet with per-session MFA.
+ // Issuing an MFA prompt starts the Webauthn goroutine which prompts for key touch on hardware level.
+ // Webauthn does not support concurrent prompts, so without this semaphore, one of the prompts would fail immediately.
+ mfaMu sync.Mutex
+ // reloginMu is used when a goroutine needs to request a relogin from the Electron app.
+ // We allow a single relogin prompt at a time.
+ reloginMu sync.Mutex
+
// usageReporter batches the events and sends them to prehog
usageReporter *usagereporter.UsageReporter
- // reloginMu is used when a goroutine needs to request a relogin from the Electron app. Since the
- // app can show only one login modal at a time, we need to submit only one request at a time.
- reloginMu sync.Mutex
// headlessWatcherClosers holds a map of root cluster URIs to headless watchers.
headlessWatcherClosers map[string]context.CancelFunc
headlessWatcherClosersMu sync.Mutex
diff --git a/lib/teleterm/daemon/daemon_headless.go b/lib/teleterm/daemon/daemon_headless.go
index 3ea8b5755d678..2662791ca17bb 100644
--- a/lib/teleterm/daemon/daemon_headless.go
+++ b/lib/teleterm/daemon/daemon_headless.go
@@ -272,10 +272,10 @@ func (s *Service) sendPendingHeadlessAuthentication(ctx context.Context, ha *typ
HeadlessAuthenticationClientIp: ha.ClientIpAddress,
}
- if err := s.importantModalSemaphore.Acquire(ctx); err != nil {
+ if err := s.headlessAuthSemaphore.Acquire(ctx); err != nil {
return trace.Wrap(err)
}
- defer s.importantModalSemaphore.Release()
+ defer s.headlessAuthSemaphore.Release()
_, err := s.tshdEventsClient.SendPendingHeadlessAuthentication(ctx, req)
return trace.Wrap(err)
diff --git a/lib/teleterm/daemon/daemon_test.go b/lib/teleterm/daemon/daemon_test.go
index 00bf1176ce03f..9c46057f7ac30 100644
--- a/lib/teleterm/daemon/daemon_test.go
+++ b/lib/teleterm/daemon/daemon_test.go
@@ -489,7 +489,7 @@ func TestRetryWithRelogin(t *testing.T) {
}
}
-func TestImportantModalSemaphore(t *testing.T) {
+func TestConcurrentHeadlessAuthPrompts(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -522,54 +522,54 @@ func TestImportantModalSemaphore(t *testing.T) {
// Claim the important modal semaphore.
customWaitDuration := 10 * time.Millisecond
- daemon.importantModalSemaphore.waitDuration = customWaitDuration
- err = daemon.importantModalSemaphore.Acquire(ctx)
+ daemon.headlessAuthSemaphore.waitDuration = customWaitDuration
+ err = daemon.headlessAuthSemaphore.Acquire(ctx)
require.NoError(t, err)
- // relogin and sending pending headless authentications should be blocked.
+ // Pending headless authentications should be blocked.
- reloginErrC := make(chan error)
+ headlessPromptErr1 := make(chan error)
go func() {
- reloginErrC <- daemon.relogin(ctx, &api.ReloginRequest{})
+ headlessPromptErr1 <- daemon.sendPendingHeadlessAuthentication(ctx, &types.HeadlessAuthentication{}, "")
}()
- sphaErrC := make(chan error)
+ headlessPromptErr2 := make(chan error)
go func() {
- sphaErrC <- daemon.sendPendingHeadlessAuthentication(ctx, &types.HeadlessAuthentication{}, "")
+ headlessPromptErr2 <- daemon.sendPendingHeadlessAuthentication(ctx, &types.HeadlessAuthentication{}, "")
}()
select {
- case <-reloginErrC:
- t.Error("relogin completed successfully without acquiring the important modal semaphore")
- case <-sphaErrC:
- t.Error("sendPendingHeadlessAuthentication completed successfully without acquiring the important modal semaphore")
+ case <-headlessPromptErr1:
+ t.Error("sendPendingHeadlessAuthentication for the first prompt completed successfully without acquiring the semaphore")
+ case <-headlessPromptErr2:
+ t.Error("sendPendingHeadlessAuthentication for the second prompt completed successfully without acquiring the semaphore")
case <-time.After(100 * time.Millisecond):
}
- // if the request's ctx is canceled, they will unblock and return an error instead.
+ // If the request's ctx is canceled, they will unblock and return an error instead.
cancelCtx, cancel := context.WithCancel(ctx)
cancel()
- err = daemon.relogin(cancelCtx, &api.ReloginRequest{})
+ err = daemon.sendPendingHeadlessAuthentication(cancelCtx, &types.HeadlessAuthentication{}, "")
require.Error(t, err)
err = daemon.sendPendingHeadlessAuthentication(cancelCtx, &types.HeadlessAuthentication{}, "")
require.Error(t, err)
- // Release the semaphore. relogin and sending pending headless authentication should
+ // Release the semaphore. Pending headless authentication should
// complete successfully after a short delay between each semaphore release.
releaseTime := time.Now()
- daemon.importantModalSemaphore.Release()
+ daemon.headlessAuthSemaphore.Release()
var otherC chan error
select {
- case err := <-reloginErrC:
+ case err := <-headlessPromptErr1:
require.NoError(t, err)
- otherC = sphaErrC
- case err := <-sphaErrC:
+ otherC = headlessPromptErr2
+ case err := <-headlessPromptErr2:
require.NoError(t, err)
- otherC = reloginErrC
+ otherC = headlessPromptErr1
case <-time.After(time.Second):
t.Error("important modal operations failed to acquire unclaimed semaphore")
}
@@ -589,8 +589,7 @@ func TestImportantModalSemaphore(t *testing.T) {
t.Error("important modal semaphore should not be acquired before waiting the specified duration")
}
- require.EqualValues(t, 1, service.reloginCount.Load(), "Unexpected number of calls to service.Relogin")
- require.EqualValues(t, 1, service.sendPendingHeadlessAuthenticationCount.Load(), "Unexpected number of calls to service.SendPendingHeadlessAuthentication")
+ require.EqualValues(t, 2, service.sendPendingHeadlessAuthenticationCount.Load(), "Unexpected number of calls to service.SendPendingHeadlessAuthentication")
}
type mockTSHDEventsService struct {
diff --git a/lib/teleterm/daemon/hardwarekeyprompt.go b/lib/teleterm/daemon/hardwarekeyprompt.go
index 4c8b5838a3b01..ad75ccf6f699b 100644
--- a/lib/teleterm/daemon/hardwarekeyprompt.go
+++ b/lib/teleterm/daemon/hardwarekeyprompt.go
@@ -30,6 +30,22 @@ import (
// NewHardwareKeyPromptConstructor returns a new hardware key prompt constructor
// for this service and the given root cluster URI.
+//
+// TODO(gzdunek): Improve multi-cluster and multi-hardware keys support.
+// The code in yubikey.go doesn't really support using multiple hardware keys (like one per cluster):
+// 1. We don't offer a choice which key should be used on the initial login.
+// 2. Keys are cached per slot, not per physical key - it's not possible to use different keys with the same slot.
+//
+// Additionally, using the same hardware key for two clusters is not ideal too.
+// Since we cache the keys per slot, if two clusters specify the same one,
+// the user will always see the prompt for the same cluster URI. For example, if you are logged into both
+// cluster-a and cluster-b, the prompt will always say "Unlock hardware key to access cluster-b."
+// It seems that the better option would be to have a prompt per physical key, not per cluster.
+// But I will leave that for the future, it's hard to say how common these scenarios will be in Connect.
+//
+// Because the code in yubikey.go assumes you use a single key, we don't have any mutex here.
+// (unlike other modals triggered by tshd).
+// We don't expect receiving prompts from different hardware keys.
func (s *Service) NewHardwareKeyPromptConstructor(rootClusterURI uri.ResourceURI) keys.HardwareKeyPrompt {
return &hardwareKeyPrompter{s: s, rootClusterURI: rootClusterURI}
}
@@ -41,10 +57,6 @@ type hardwareKeyPrompter struct {
// Touch prompts the user to touch the hardware key.
func (h *hardwareKeyPrompter) Touch(ctx context.Context) error {
- if err := h.s.importantModalSemaphore.Acquire(ctx); err != nil {
- return trace.Wrap(err)
- }
- defer h.s.importantModalSemaphore.Release()
_, err := h.s.tshdEventsClient.PromptHardwareKeyTouch(ctx, &api.PromptHardwareKeyTouchRequest{
RootClusterUri: h.rootClusterURI.String(),
})
@@ -56,10 +68,6 @@ func (h *hardwareKeyPrompter) Touch(ctx context.Context) error {
// AskPIN prompts the user for a PIN.
func (h *hardwareKeyPrompter) AskPIN(ctx context.Context, requirement keys.PINPromptRequirement) (string, error) {
- if err := h.s.importantModalSemaphore.Acquire(ctx); err != nil {
- return "", trace.Wrap(err)
- }
- defer h.s.importantModalSemaphore.Release()
res, err := h.s.tshdEventsClient.PromptHardwareKeyPIN(ctx, &api.PromptHardwareKeyPINRequest{
RootClusterUri: h.rootClusterURI.String(),
PinOptional: requirement == keys.PINOptional,
@@ -74,10 +82,6 @@ func (h *hardwareKeyPrompter) AskPIN(ctx context.Context, requirement keys.PINPr
// The Electron app prompt must handle default values for PIN and PUK,
// preventing the user from submitting empty/default values.
func (h *hardwareKeyPrompter) ChangePIN(ctx context.Context) (*keys.PINAndPUK, error) {
- if err := h.s.importantModalSemaphore.Acquire(ctx); err != nil {
- return nil, trace.Wrap(err)
- }
- defer h.s.importantModalSemaphore.Release()
res, err := h.s.tshdEventsClient.PromptHardwareKeyPINChange(ctx, &api.PromptHardwareKeyPINChangeRequest{
RootClusterUri: h.rootClusterURI.String(),
})
@@ -93,10 +97,6 @@ func (h *hardwareKeyPrompter) ChangePIN(ctx context.Context) (*keys.PINAndPUK, e
// ConfirmSlotOverwrite asks the user if the slot's private key and certificate can be overridden.
func (h *hardwareKeyPrompter) ConfirmSlotOverwrite(ctx context.Context, message string) (bool, error) {
- if err := h.s.importantModalSemaphore.Acquire(ctx); err != nil {
- return false, trace.Wrap(err)
- }
- defer h.s.importantModalSemaphore.Release()
res, err := h.s.tshdEventsClient.ConfirmHardwareKeySlotOverwrite(ctx, &api.ConfirmHardwareKeySlotOverwriteRequest{
RootClusterUri: h.rootClusterURI.String(),
Message: message,
diff --git a/lib/teleterm/daemon/mfaprompt.go b/lib/teleterm/daemon/mfaprompt.go
index abdcd3dc2089c..adeda73e1518b 100644
--- a/lib/teleterm/daemon/mfaprompt.go
+++ b/lib/teleterm/daemon/mfaprompt.go
@@ -63,10 +63,8 @@ func (s *Service) NewMFAPrompt(resourceURI uri.ResourceURI, cfg *libmfa.PromptCo
}
func (s *Service) promptAppMFA(ctx context.Context, in *api.PromptMFARequest) (*api.PromptMFAResponse, error) {
- if err := s.importantModalSemaphore.Acquire(ctx); err != nil {
- return nil, trace.Wrap(err)
- }
- defer s.importantModalSemaphore.Release()
+ s.mfaMu.Lock()
+ defer s.mfaMu.Unlock()
return s.tshdEventsClient.PromptMFA(ctx, in)
}
diff --git a/web/packages/design/src/DialogConfirmation/DialogConfirmation.story.jsx b/web/packages/design/src/DialogConfirmation/DialogConfirmation.story.tsx
similarity index 97%
rename from web/packages/design/src/DialogConfirmation/DialogConfirmation.story.jsx
rename to web/packages/design/src/DialogConfirmation/DialogConfirmation.story.tsx
index 5c8ea7fc371df..51e5e25ed7b8a 100644
--- a/web/packages/design/src/DialogConfirmation/DialogConfirmation.story.jsx
+++ b/web/packages/design/src/DialogConfirmation/DialogConfirmation.story.tsx
@@ -16,8 +16,6 @@
* along with this program. If not, see .
*/
-import React from 'react';
-
import { ButtonPrimary } from '../Button';
import DialogConfirmation, {
diff --git a/web/packages/design/src/DialogConfirmation/DialogConfirmation.test.jsx b/web/packages/design/src/DialogConfirmation/DialogConfirmation.test.tsx
similarity index 92%
rename from web/packages/design/src/DialogConfirmation/DialogConfirmation.test.jsx
rename to web/packages/design/src/DialogConfirmation/DialogConfirmation.test.tsx
index a1974fe1fc247..4887037fd3974 100644
--- a/web/packages/design/src/DialogConfirmation/DialogConfirmation.test.jsx
+++ b/web/packages/design/src/DialogConfirmation/DialogConfirmation.test.tsx
@@ -16,11 +16,9 @@
* along with this program. If not, see .
*/
-import React from 'react';
-
import { render, fireEvent } from 'design/utils/testing';
-import DialogConfirmation from './DialogConfirmation';
+import { DialogConfirmation } from './DialogConfirmation';
test('onClose is respected', () => {
const onClose = jest.fn();
diff --git a/web/packages/design/src/DialogConfirmation/DialogConfirmation.jsx b/web/packages/design/src/DialogConfirmation/DialogConfirmation.tsx
similarity index 52%
rename from web/packages/design/src/DialogConfirmation/DialogConfirmation.jsx
rename to web/packages/design/src/DialogConfirmation/DialogConfirmation.tsx
index f15f1b5c8604c..80b0c33ead6d7 100644
--- a/web/packages/design/src/DialogConfirmation/DialogConfirmation.jsx
+++ b/web/packages/design/src/DialogConfirmation/DialogConfirmation.tsx
@@ -16,22 +16,36 @@
* along with this program. If not, see .
*/
-import React from 'react';
+import { ReactNode } from 'react';
+import { StyleFunction } from 'styled-components';
import Dialog from 'design/Dialog';
-function DialogConfirmation(props) {
- const { children, open, onClose, dialogCss } = props;
+export function DialogConfirmation(props: {
+ open: boolean;
+ /**
+ * Prevent unmounting the component and its children from the DOM when closed.
+ * Instead, hides it with CSS.
+ */
+ keepInDOMAfterClose?: boolean;
+ /** @deprecated This props has no effect, it was never passed down to `Dialog`. */
+ disableEscapeKeyDown?: boolean;
+ children?: ReactNode;
+ onClose?: (
+ event: KeyboardEvent | React.MouseEvent,
+ reason: 'escapeKeyDown' | 'backdropClick'
+ ) => void;
+ dialogCss?: StyleFunction;
+}) {
return (
);
}
-
-export default DialogConfirmation;
diff --git a/web/packages/design/src/DialogConfirmation/index.js b/web/packages/design/src/DialogConfirmation/index.ts
similarity index 94%
rename from web/packages/design/src/DialogConfirmation/index.js
rename to web/packages/design/src/DialogConfirmation/index.ts
index 4a2b0c46d6057..62a4d56409401 100644
--- a/web/packages/design/src/DialogConfirmation/index.js
+++ b/web/packages/design/src/DialogConfirmation/index.ts
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-import DialogConfirmation from './DialogConfirmation';
+import { DialogConfirmation } from './DialogConfirmation';
import {
DialogTitle,
DialogContent,
diff --git a/web/packages/design/src/Modal/Modal.test.tsx b/web/packages/design/src/Modal/Modal.test.tsx
index 0c0e33aa965db..09a63cdfc9df3 100644
--- a/web/packages/design/src/Modal/Modal.test.tsx
+++ b/web/packages/design/src/Modal/Modal.test.tsx
@@ -36,128 +36,144 @@ const escapeKey = {
key: 'Escape',
};
-describe('design/Modal', () => {
- it('respects open prop set to false', () => {
- render(
-
- Hello
-
- );
+test('respects open prop set to false', () => {
+ render(
+
+ Hello
+
+ );
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
+
+test('respects onBackdropClick prop', () => {
+ const mockFn = jest.fn();
+
+ renderModal({
+ onBackdropClick: mockFn,
});
- it('respects onBackdropClick prop', () => {
- const mockFn = jest.fn();
+ // handlebackdropClick
+ fireEvent.click(screen.getByTestId('backdrop'));
+ expect(mockFn).toHaveBeenCalled();
+});
- renderModal({
- onBackdropClick: mockFn,
- });
+test('respects onEscapeKeyDown props', () => {
+ const mockFn = jest.fn();
- // handlebackdropClick
- fireEvent.click(screen.getByTestId('backdrop'));
- expect(mockFn).toHaveBeenCalled();
+ const { container } = renderModal({
+ onEscapeKeyDown: mockFn,
});
- it('respects onEscapeKeyDown props', () => {
- const mockFn = jest.fn();
+ // handleDocumentKeyDown
+ fireEvent.keyDown(container, escapeKey);
+ expect(mockFn).toHaveBeenCalled();
+});
- const { container } = renderModal({
- onEscapeKeyDown: mockFn,
- });
+test('respects onClose prop', () => {
+ const mockFn = jest.fn();
- // handleDocumentKeyDown
- fireEvent.keyDown(container, escapeKey);
- expect(mockFn).toHaveBeenCalled();
+ const { container } = renderModal({
+ onClose: mockFn,
});
- it('respects onClose prop', () => {
- const mockFn = jest.fn();
-
- const { container } = renderModal({
- onClose: mockFn,
- });
+ // handlebackdropClick
+ fireEvent.click(screen.getByTestId('backdrop'));
+ expect(mockFn).toHaveBeenCalled();
- // handlebackdropClick
- fireEvent.click(screen.getByTestId('backdrop'));
- expect(mockFn).toHaveBeenCalled();
+ // handleDocumentKeyDown
+ fireEvent.keyDown(container, escapeKey);
+ expect(mockFn).toHaveBeenCalled();
+});
- // handleDocumentKeyDown
- fireEvent.keyDown(container, escapeKey);
- expect(mockFn).toHaveBeenCalled();
+test('respects hideBackDrop prop', () => {
+ renderModal({
+ hideBackdrop: true,
});
- it('respects hideBackDrop prop', () => {
- renderModal({
- hideBackdrop: true,
- });
+ expect(screen.queryByTestId('backdrop')).not.toBeInTheDocument();
+});
- expect(screen.queryByTestId('backdrop')).not.toBeInTheDocument();
+test('respects disableBackdropClick prop', () => {
+ const mockFn = jest.fn();
+ renderModal({
+ disableBackdropClick: true,
+ onClose: mockFn,
});
- it('respects disableBackdropClick prop', () => {
- const mockFn = jest.fn();
- renderModal({
- disableBackdropClick: true,
- onClose: mockFn,
- });
+ // handleBackdropClick
+ fireEvent.click(screen.getByTestId('backdrop'));
+ expect(mockFn).not.toHaveBeenCalled();
+});
- // handleBackdropClick
- fireEvent.click(screen.getByTestId('backdrop'));
- expect(mockFn).not.toHaveBeenCalled();
+test('respects disableEscapeKeyDown prop', () => {
+ const mockFn = jest.fn();
+ const { container } = renderModal({
+ disableEscapeKeyDown: true,
+ onClose: mockFn,
});
- it('respects disableEscapeKeyDown prop', () => {
- const mockFn = jest.fn();
- const { container } = renderModal({
- disableEscapeKeyDown: true,
- onClose: mockFn,
- });
+ // handleDocumentKeyDown
+ fireEvent.keyDown(container, escapeKey);
+ expect(mockFn).not.toHaveBeenCalled();
+});
- // handleDocumentKeyDown
- fireEvent.keyDown(container, escapeKey);
- expect(mockFn).not.toHaveBeenCalled();
+test('unmount cleans up event listeners and closes modal', () => {
+ const mockFn = jest.fn();
+ const { container, unmount } = renderModal({
+ onEscapeKeyDown: mockFn,
});
- test('unmount cleans up event listeners and closes modal', () => {
- const mockFn = jest.fn();
- const { container, unmount } = renderModal({
- onEscapeKeyDown: mockFn,
- });
+ unmount();
- unmount();
+ expect(screen.queryByTestId('Modal')).not.toBeInTheDocument();
- expect(screen.queryByTestId('Modal')).not.toBeInTheDocument();
+ fireEvent.keyDown(container, escapeKey);
+ expect(mockFn).not.toHaveBeenCalled();
+});
- fireEvent.keyDown(container, escapeKey);
- expect(mockFn).not.toHaveBeenCalled();
+test('respects backdropProps prop invisible', () => {
+ renderModal({
+ BackdropProps: { invisible: true },
});
- test('respects backdropProps prop invisible', () => {
- renderModal({
- BackdropProps: { invisible: true },
- });
-
- expect(screen.getByTestId('backdrop')).toHaveStyle({
- 'background-color': 'transparent',
- });
+ expect(screen.getByTestId('backdrop')).toHaveStyle({
+ 'background-color': 'transparent',
});
+});
- it('restores focus on close', async () => {
- const user = userEvent.setup();
- render();
- const toggleModalButton = screen.getByRole('button', { name: 'Toggle' });
+test('restores focus on close', async () => {
+ const user = userEvent.setup();
+ render();
+ const toggleModalButton = screen.getByRole('button', { name: 'Toggle' });
- await user.click(toggleModalButton);
- // Type in the input inside the modal to shift focus into another element.
- const input = screen.getByLabelText('Input in modal');
- await user.type(input, 'a');
+ await user.click(toggleModalButton);
+ // Type in the input inside the modal to shift focus into another element.
+ const input = screen.getByLabelText('Input in modal');
+ await user.type(input, 'a');
- const closeModal = screen.getByRole('button', { name: 'Close modal' });
- await user.click(closeModal);
+ const closeModal = screen.getByRole('button', { name: 'Close modal' });
+ await user.click(closeModal);
- expect(toggleModalButton).toHaveFocus();
- });
+ expect(toggleModalButton).toHaveFocus();
+});
+
+test('closing dialog when attachCustomKeyEventHandler is set only hides it with DOM', async () => {
+ const { rerender } = render(
+
+ Hello
+
+ );
+
+ expect(screen.getByText('Hello')).toBeVisible();
+
+ rerender(
+
+ Hello
+
+ );
+
+ expect(screen.getByText('Hello')).not.toBeVisible();
});
const ToggleableModal = () => {
diff --git a/web/packages/design/src/Modal/Modal.tsx b/web/packages/design/src/Modal/Modal.tsx
index e93085d426e7f..99eaf6f2b286d 100644
--- a/web/packages/design/src/Modal/Modal.tsx
+++ b/web/packages/design/src/Modal/Modal.tsx
@@ -25,6 +25,11 @@ export type ModalProps = {
* If `true`, the modal is open.
*/
open: boolean;
+ /**
+ * Prevent unmounting the component and its children from the DOM when closed.
+ * Instead, hides it with CSS.
+ */
+ keepInDOMAfterClose?: boolean;
className?: string;
@@ -200,15 +205,23 @@ export default class Modal extends React.Component {
}
render() {
- const { BackdropProps, children, modalCss, hideBackdrop, open, className } =
- this.props;
-
- if (!open) {
+ const {
+ BackdropProps,
+ children,
+ modalCss,
+ hideBackdrop,
+ open,
+ className,
+ keepInDOMAfterClose,
+ } = this.props;
+
+ if (!open && !keepInDOMAfterClose) {
return null;
}
return createPortal(
`
const StyledModal = styled.div<{
modalCss?: StyleFunction;
ref: React.RefObject;
+ hiddenInDom?: boolean;
}>`
position: fixed;
z-index: 1200;
@@ -268,5 +282,6 @@ const StyledModal = styled.div<{
bottom: 0;
top: 0;
left: 0;
+ ${props => props.hiddenInDom && `display: none;`};
${props => props.modalCss?.(props)}
`;
diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterConnect.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterConnect.tsx
index 5a88fd2f71a2c..9a3f5cac5c54b 100644
--- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterConnect.tsx
+++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterConnect.tsx
@@ -28,7 +28,10 @@ import { ClusterAdd } from './ClusterAdd';
import { ClusterLogin } from './ClusterLogin';
import { dialogCss } from './spacing';
-export function ClusterConnect(props: { dialog: DialogClusterConnect }) {
+export function ClusterConnect(props: {
+ dialog: DialogClusterConnect;
+ hidden?: boolean;
+}) {
const [createdClusterUri, setCreatedClusterUri] = useState<
RootClusterUri | undefined
>();
@@ -49,7 +52,8 @@ export function ClusterConnect(props: { dialog: DialogClusterConnect }) {
dialogCss={dialogCss}
disableEscapeKeyDown={false}
onClose={props.dialog.onCancel}
- open={true}
+ open={!props.hidden}
+ keepInDOMAfterClose
>
{!clusterUri ? (
({
maxWidth: '400px',
diff --git a/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx b/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx
index 7e9e5d3603a57..33d17aeb959a3 100644
--- a/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx
+++ b/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx
@@ -31,14 +31,13 @@ import { P } from 'design/Text/Text';
import { RootClusterUri, routing } from 'teleterm/ui/uri';
import { useAppContext } from 'teleterm/ui/appContextProvider';
-interface DocumentsReopenProps {
+export function DocumentsReopen(props: {
rootClusterUri: RootClusterUri;
numberOfDocuments: number;
onCancel(): void;
onConfirm(): void;
-}
-
-export function DocumentsReopen(props: DocumentsReopenProps) {
+ hidden?: boolean;
+}) {
const { rootClusterUri } = props;
const { clustersService } = useAppContext();
// TODO(ravicious): Use a profile name here from the URI and remove the dependency on
@@ -49,7 +48,8 @@ export function DocumentsReopen(props: DocumentsReopenProps) {
return (
({
maxWidth: '400px',
diff --git a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx
index d395dd8f9f03d..1cb1a0e5e20c4 100644
--- a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx
+++ b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx
@@ -36,6 +36,7 @@ interface HeadlessAuthenticationProps {
skipConfirm: boolean;
onCancel(): void;
onSuccess(): void;
+ hidden?: boolean;
}
export function HeadlessAuthentication(props: HeadlessAuthenticationProps) {
@@ -81,6 +82,7 @@ export function HeadlessAuthentication(props: HeadlessAuthenticationProps) {
return (
({
maxWidth: '480px',
width: '100%',
})}
- disableEscapeKeyDown={false}
- open={true}
>
diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.story.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.story.tsx
index 5997e8ae85f89..b09eeff930c91 100644
--- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.story.tsx
+++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.story.tsx
@@ -21,9 +21,10 @@ import React from 'react';
import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import {
- DialogClusterLogout,
DialogDocumentsReopen,
ModalsService,
+ DialogHardwareKeyTouch,
+ DialogHardwareKeyPin,
} from 'teleterm/ui/services/modals';
import ModalsHost from './ModalsHost';
@@ -32,10 +33,22 @@ export default {
title: 'Teleterm/ModalsHost',
};
-const clusterLogoutDialog: DialogClusterLogout = {
- kind: 'cluster-logout',
- clusterUri: '/clusters/foo',
- clusterTitle: 'Foo',
+const hardwareKeyTouchDialog: DialogHardwareKeyTouch = {
+ kind: 'hardware-key-touch',
+ req: {
+ rootClusterUri: '/clusters/foo',
+ },
+ onCancel: () => {},
+};
+
+const hardwareKeyPinDialog: DialogHardwareKeyPin = {
+ kind: 'hardware-key-pin',
+ req: {
+ rootClusterUri: '/clusters/foo',
+ pinOptional: false,
+ },
+ onSuccess: () => {},
+ onCancel: () => {},
};
const documentsReopenDialog: DialogDocumentsReopen = {
@@ -46,7 +59,7 @@ const documentsReopenDialog: DialogDocumentsReopen = {
onCancel: () => {},
};
-const importantDialog = clusterLogoutDialog;
+const importantDialog = hardwareKeyTouchDialog;
const regularDialog = documentsReopenDialog;
export const RegularModal = () => {
@@ -91,3 +104,18 @@ export const ImportantAndRegularModal = () => {
);
};
+
+export const TwoImportantModals = () => {
+ const modalsService = new ModalsService();
+ modalsService.openImportantDialog(importantDialog);
+ modalsService.openImportantDialog(hardwareKeyPinDialog);
+
+ const appContext = new MockAppContext();
+ appContext.modalsService = modalsService;
+
+ return (
+
+
+
+ );
+};
diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.test.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.test.tsx
index 7e93fa8fc88e8..de5743e01dac0 100644
--- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.test.tsx
+++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.test.tsx
@@ -18,21 +18,34 @@
import React from 'react';
import { render, screen } from 'design/utils/testing';
+import { act } from '@testing-library/react';
import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import {
- DialogClusterLogout,
- DialogDocumentsReopen,
ModalsService,
+ DialogDocumentsReopen,
+ DialogClusterConnect,
+ DialogHardwareKeyTouch,
} from 'teleterm/ui/services/modals';
import ModalsHost from './ModalsHost';
-const clusterLogoutDialog: DialogClusterLogout = {
- kind: 'cluster-logout',
+const clusterConnectDialog: DialogClusterConnect = {
+ kind: 'cluster-connect',
clusterUri: '/clusters/foo',
- clusterTitle: 'Foo',
+ reason: undefined,
+ onCancel: () => {},
+ onSuccess: () => {},
+ prefill: undefined,
+};
+
+const hardwareKeyTouchDialog: DialogHardwareKeyTouch = {
+ kind: 'hardware-key-touch',
+ req: {
+ rootClusterUri: '/clusters/foo',
+ },
+ onCancel: () => {},
};
const documentsReopenDialog: DialogDocumentsReopen = {
@@ -43,26 +56,38 @@ const documentsReopenDialog: DialogDocumentsReopen = {
onCancel: () => {},
};
-jest.mock('teleterm/ui/ClusterLogout', () => ({
- ClusterLogout: () => (
+jest.mock('teleterm/ui/ClusterConnect', () => ({
+ ClusterConnect: props => (
+
+ ),
+}));
+
+jest.mock('teleterm/ui/ModalsHost/modals/HardwareKeys/Touch', () => ({
+ Touch: props => (
+ data-dialog-kind="hardware-key-touch"
+ data-dialog-is-hidden={props.hidden}
+ />
),
}));
jest.mock('teleterm/ui/DocumentsReopen', () => ({
- DocumentsReopen: () => (
+ DocumentsReopen: props => (
+ data-dialog-kind="documents-reopen"
+ data-dialog-is-hidden={props.hidden}
+ />
),
}));
test('the important dialog is rendered above the regular dialog', () => {
- const importantDialog = clusterLogoutDialog;
+ const importantDialog = clusterConnectDialog;
const regularDialog = documentsReopenDialog;
const modalsService = new ModalsService();
@@ -86,5 +111,48 @@ test('the important dialog is rendered above the regular dialog', () => {
// The important dialog should be after the regular dialog in the DOM so that it's shown over the
// regular dialog.
expect(dialogs[0]).toHaveAttribute('data-dialog-kind', regularDialog.kind);
+ expect(dialogs[0]).toHaveAttribute('data-dialog-is-hidden', 'true');
expect(dialogs[1]).toHaveAttribute('data-dialog-kind', importantDialog.kind);
+ expect(dialogs[1]).toHaveAttribute('data-dialog-is-hidden', 'false');
+});
+
+test('the second important dialog is rendered above the first important dialog', () => {
+ const importantDialog1 = clusterConnectDialog;
+ const importantDialog2 = hardwareKeyTouchDialog;
+
+ const modalsService = new ModalsService();
+ modalsService.openImportantDialog(importantDialog1);
+ const { closeDialog: closeImportantDialog2 } =
+ modalsService.openImportantDialog(importantDialog2);
+
+ const appContext = new MockAppContext();
+ appContext.modalsService = modalsService;
+
+ render(
+
+
+
+ );
+
+ // The DOM testing library doesn't really allow us to test actual visibility in terms of the order
+ // of rendering, so we have to fall back to manually checking items in the array.
+ // https://github.com/testing-library/react-testing-library/issues/313
+ let dialogs = screen.queryAllByTestId('mocked-dialog');
+
+ // The second important dialog should be after the regular dialog in the DOM so that it's shown over the
+ // first important dialog.
+ // On top of that, only the important dialog on the top should be visible.
+ expect(dialogs[0]).toHaveAttribute('data-dialog-kind', importantDialog1.kind);
+ expect(dialogs[0]).toHaveAttribute('data-dialog-is-hidden', 'true');
+ expect(dialogs[1]).toHaveAttribute('data-dialog-kind', importantDialog2.kind);
+ expect(dialogs[1]).toHaveAttribute('data-dialog-is-hidden', 'false');
+
+ act(() => closeImportantDialog2());
+
+ dialogs = screen.queryAllByTestId('mocked-dialog');
+
+ // The dialog previously on top was closed.
+ // Now the first dialog is visible.
+ expect(dialogs[0]).toHaveAttribute('data-dialog-kind', importantDialog1.kind);
+ expect(dialogs[0]).toHaveAttribute('data-dialog-is-hidden', 'false');
});
diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
index c43bd061baba8..3ccb6f6c164ad 100644
--- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
+++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-import React from 'react';
+import React, { Fragment } from 'react';
import { useAppContext } from 'teleterm/ui/appContextProvider';
@@ -38,25 +38,59 @@ import { AskPin, ChangePin, OverwriteSlot, Touch } from './modals/HardwareKeys';
export default function ModalsHost() {
const { modalsService } = useAppContext();
- const { regular: regularDialog, important: importantDialog } =
+ const { regular: regularDialog, important: importantDialogs } =
modalsService.useState();
const closeRegularDialog = () => modalsService.closeRegularDialog();
- const closeImportantDialog = () => modalsService.closeImportantDialog();
return (
<>
- {renderDialog(regularDialog, closeRegularDialog)}
- {renderDialog(importantDialog, closeImportantDialog)}
+ {renderDialog({
+ dialog: regularDialog,
+ handleClose: closeRegularDialog,
+ hidden: !!importantDialogs.length,
+ })}
+ {importantDialogs.map(({ dialog, id }, index) => {
+ const isLast = index === importantDialogs.length - 1;
+ return (
+
+ {renderDialog({
+ dialog: dialog,
+ handleClose: () => modalsService.closeImportantDialog(id),
+ hidden: !isLast,
+ })}
+
+ );
+ })}
>
);
}
-function renderDialog(dialog: Dialog, handleClose: () => void) {
+/**
+ * Renders a dialog.
+ * Each dialog must implement a `hidden` prop which visually hides the dialog
+ * without unmounting it.
+ * This is needed because tshd may want to display more than one dialog.
+ * Also, we hide a regular dialog, when an important one is visible.
+ */
+function renderDialog({
+ dialog,
+ handleClose,
+ hidden,
+}: {
+ dialog: Dialog;
+ handleClose: () => void;
+ hidden: boolean;
+}) {
+ if (!dialog) {
+ return null;
+ }
+
switch (dialog.kind) {
case 'device-trust-authorize': {
return (
{
@@ -70,6 +104,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
case 'cluster-connect': {
return (
{
@@ -87,6 +122,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
case 'cluster-logout': {
return (
void) {
case 'documents-reopen': {
return (
{
@@ -112,6 +149,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
case 'usage-data': {
return (
{
handleClose();
dialog.onCancel();
@@ -130,6 +168,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
case 'user-job-role': {
return (
{
handleClose();
dialog.onCancel();
@@ -141,10 +180,10 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
/>
);
}
-
case 'resource-search-errors': {
return (
{
@@ -154,10 +193,10 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
/>
);
}
-
case 'headless-authn': {
return (
void) {
/>
);
}
-
case 'reauthenticate': {
return (
{
handleClose();
@@ -194,6 +233,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
case 'change-access-request-kind': {
return (
{
handleClose();
dialog.onConfirm();
@@ -208,6 +248,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
case 'hardware-key-pin': {
return (
{
handleClose();
@@ -223,6 +264,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
case 'hardware-key-touch': {
return (
{
handleClose();
@@ -234,6 +276,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
case 'hardware-key-pin-change': {
return (
{
@@ -246,6 +289,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
case 'hardware-key-slot-overwrite': {
return (
{
@@ -256,10 +300,6 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
);
}
- case 'none': {
- return null;
- }
-
default: {
return assertUnreachable(dialog);
}
diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.tsx
index 376c8ef7803b7..5c97b8d3ddad5 100644
--- a/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.tsx
+++ b/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.tsx
@@ -25,19 +25,19 @@ import { useAsync } from 'shared/hooks/useAsync';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { RootClusterUri, routing } from 'teleterm/ui/uri';
-type Props = {
- rootClusterUri: RootClusterUri;
- onCancel(): void;
- onClose(): void;
- onAuthorize(): Promise;
-};
-
export const AuthenticateWebDevice = ({
+ hidden,
onAuthorize,
onClose,
onCancel,
rootClusterUri,
-}: Props) => {
+}: {
+ rootClusterUri: RootClusterUri;
+ onCancel(): void;
+ onClose(): void;
+ onAuthorize(): Promise;
+ hidden?: boolean;
+}) => {
const [attempt, run] = useAsync(async () => {
await onAuthorize();
onClose();
@@ -48,7 +48,7 @@ export const AuthenticateWebDevice = ({
routing.parseClusterName(rootClusterUri);
return (
-