Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions web/packages/teleterm/src/ui/services/modals/modalsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ export class ModalsService extends ImmutableStore<State> {
*
* Calling openRegularDialog while another regular dialog is displayed will simply overwrite the
* old dialog with the new one.
* The old dialog is canceled, if possible.
*
* The returned closeDialog function can be used to close the dialog and automatically call the
* dialog's onCancel callback (if present).
*/
openRegularDialog(dialog: Dialog): { closeDialog: () => void } {
this.state.regular['onCancel']?.();
this.setState(draftState => {
draftState.regular = dialog;
});
Expand All @@ -80,11 +82,13 @@ export class ModalsService extends ImmutableStore<State> {
*
* Calling openImportantDialog while another important dialog is displayed will simply overwrite
* the old dialog with the new one.
* The old dialog is canceled, if possible.
*
* The returned closeDialog function can be used to close the dialog and automatically call the
* dialog's onCancel callback (if present).
*/
openImportantDialog(dialog: Dialog): { closeDialog: () => void } {
this.state.important['onCancel']?.();
this.setState(draftState => {
draftState.important = dialog;
});
Expand Down
71 changes: 71 additions & 0 deletions web/packages/teleterm/src/ui/utils/retryWithRelogin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { waitFor } from '@testing-library/react';

import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import Logger, { NullService } from 'teleterm/logger';

Expand Down Expand Up @@ -166,3 +168,72 @@ it('calls actionToRetry again if relogin attempt was canceled', async () => {
expect(actionToRetry).toHaveBeenCalledTimes(2);
expect(actualReturnValue).toEqual(expectedReturnValue);
});

it('concurrent requests wait for the single login modal to resolve', async () => {
const appContext = new MockAppContext();

let logIn: () => void;
jest
.spyOn(appContext.modalsService, 'openRegularDialog')
.mockImplementation(dialog => {
if (dialog.kind === 'cluster-connect') {
logIn = () => dialog.onSuccess('/clusters/foo');
} else {
throw new Error(`Got unexpected dialog ${dialog.kind}`);
}

// Dialog cancel function.
return {
closeDialog: () => {},
};
});

jest
.spyOn(appContext.workspacesService, 'doesResourceBelongToActiveWorkspace')
.mockImplementation(() => true);

const firstExpectedReturnValue = Symbol('firstExpectedReturnValue');
const secondExpectedReturnValue = Symbol('secondExpectedReturnValue');
const firstActionToRetry = jest
.fn()
.mockRejectedValueOnce(makeRetryableError())
.mockResolvedValueOnce(firstExpectedReturnValue);
const secondActionToRetry = jest
.fn()
.mockRejectedValueOnce(makeRetryableError())
.mockResolvedValueOnce(secondExpectedReturnValue);

const firstAction = retryWithRelogin(
appContext,
'/clusters/foo/servers/bar',
firstActionToRetry
);
const secondAction = retryWithRelogin(
appContext,
'/clusters/foo/servers/xyz',
secondActionToRetry
);

const openRegularDialogSpy = appContext.modalsService.openRegularDialog;
await waitFor(() => {
expect(openRegularDialogSpy).toHaveBeenCalledTimes(1);
});
expect(openRegularDialogSpy).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'cluster-connect',
clusterUri: '/clusters/foo',
})
);

logIn();

const firstActionExpectedReturnValue = await firstAction;
const secondActionExpectedReturnValue = await secondAction;

expect(firstActionToRetry).toHaveBeenCalledTimes(2);
expect(secondActionToRetry).toHaveBeenCalledTimes(2);
expect(firstActionExpectedReturnValue).toEqual(firstExpectedReturnValue);
expect(secondActionExpectedReturnValue).toEqual(secondExpectedReturnValue);

expect(openRegularDialogSpy).toHaveBeenCalledTimes(1);
});
14 changes: 13 additions & 1 deletion web/packages/teleterm/src/ui/utils/retryWithRelogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import Logger from 'teleterm/logger';

const logger = new Logger('retryWithRelogin');

let pendingLoginDialog: Promise<void> | undefined;

/**
* `retryWithRelogin` executes `actionToRetry`. If `actionToRetry` throws an error, it checks if the
* error can be resolved by the user logging in, according to metadata returned from the tshd
Expand All @@ -31,6 +33,10 @@ const logger = new Logger('retryWithRelogin');
* argument) and if so, it shows a login modal. After the user successfully logs in, it calls
* `actionToRetry` again.
*
* `retryWithRelogin` supports concurrent requests.
* If multiple actions need to show a login modal at the same time,
* it will be displayed only once, and other actions will wait for it to be resolved.
*
* Each place using `retryWithRelogin` must be able to show the error to the user in case the
* relogin attempt fails. Each place should also offer the user a way to manually retry the action
* which results in a call to the tshd client.
Expand Down Expand Up @@ -75,7 +81,13 @@ export async function retryWithRelogin<T>(

const rootClusterUri = routing.ensureRootClusterUri(resourceUri);

await login(appContext, rootClusterUri);
if (!pendingLoginDialog) {
pendingLoginDialog = login(appContext, rootClusterUri).finally(() => {
pendingLoginDialog = undefined;
});
}

await pendingLoginDialog;

return await actionToRetry();
}
Expand Down