Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent activation of previous workspace when launching Connect via deep link to a different cluster #50063

Open
wants to merge 10 commits into
base: master
Choose a base branch
from

Conversation

gzdunek
Copy link
Contributor

@gzdunek gzdunek commented Dec 11, 2024

Closes #40962

Problem

Connect always tries to restore the previous workspace, even when it is run via a deep link. This is incorrect behavior- if the user wants to authorize a session for cluster A, we shouldn't force him to log in to a cluster B first.

Solution

Currently, restoring the workspaces from disk also actives the previously active workspace (this includes showing a login dialog if certs expired). Opening a deep link waits for that state to be restored, so it also waits for that dialog to be completed.
We don't want this, so we need to decouple workspace restoration from activating the previously active workspace. This will allow us to signal the frontend interface readiness before the login dialog finishes. When the frontend is ready, a deep link can be processed to activate its corresponding workspace.
Our modal service allows only one regular dialog is active at any time, so opening a new dialog will automatically cancel an existing one.

In other words:

  1. The user has workspaces A and B, with workspace B previously active.
  2. The user launches a deep link to cluster A.
  3. The app restores the state (workspaces A and B) and starts an attempt to set workspace B as active. This attempt does not block the app's initialization.
  4. Since the app is initialized, the deep link is processed, immediately replacing the dialog for activating workspace B with one for activating workspace A. This transition happens so quickly that the user doesn’t see the initial dialog.

While working on this fix, I cleaned up some code in workspacesService and documentsService:

  • setActiveWorkspace had a mix of async/await and .then, it has been rewritten to the newer syntax.
  • I moved some methods that do not need state outside the services.
  • I got rid of previous field in the workspace state. Instead, I store the entire restored state in a separate variable. I needed this, because if we don't restore the previous rootClusterUri immediately, it is overwritten with undefined in setState. I think this approach is actually simpler than what we had before.

@gzdunek gzdunek added no-changelog Indicates that a PR does not require a changelog entry backport/branch/v16 backport/branch/v17 labels Dec 11, 2024
@gzdunek gzdunek requested review from ravicious and avatus December 11, 2024 16:01
@github-actions github-actions bot requested a review from rudream December 11, 2024 16:01
@gzdunek gzdunek removed the request for review from rudream December 11, 2024 16:02
Copy link
Member

@ravicious ravicious left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So far I only looked through the code, I'll try to test this on Monday.

const persistedState = this.statePersistenceService.getWorkspacesState();
restorePersistedState(): void {
const restoredState = this.statePersistenceService.getWorkspacesState();
this.restoredState = restoredState;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way we can guarantee that this is not modified? Keep it as imerrized object instead of a standard JS one or something? Have a private setter and a public getter that always calls structuredClone before returning the value?

The way it is right now, it's way to easy to overwrite it by mistake or hand out references to objects inside this.restoredState. I tried using microsoft/TypeScript#13923 (comment) which would be pretty handy – the compiler would tell you to deep clone parts that you'd like to use for mutable purposes. Unfortunately, I couldn't come up with the right types for a function that would take a deep readonly value, call structuredClone on it and return a "deep mutable" version of it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea to make it immutable! I decided to use produce and Immutable from immer.js, I think it's the simplest option.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, that seems much better than everything that I suggested.

Copy link
Contributor

@avatus avatus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a scan of the code but tested locally and it works. Leaving premp approval because I'm out next week

@ravicious ravicious self-requested a review December 13, 2024 15:36
@@ -119,6 +115,12 @@ export class WorkspacesService extends ImmutableStore<WorkspacesState> {
workspaces: {},
isInitialized: false,
};
/**
* Keeps the state restored when the app was launched.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Keeps the state restored when the app was launched.
* Keeps the state that was restored from disk when the app was launched.

Comment on lines +505 to +507
resourceKinds: d.queryParams?.resourceKinds
? [...d.queryParams.resourceKinds] // makes the array mutable
: defaultParams.resourceKinds,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder why commenting out sort above doesn't trigger a similar type error. I suppose types for readonly objects behave differently than types for readonly arrays?

const persistedState = this.statePersistenceService.getWorkspacesState();
restorePersistedState(): void {
const restoredState = this.statePersistenceService.getWorkspacesState();
this.restoredState = restoredState;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, that seems much better than everything that I suggested.

});
break;
case 'discarded':
this.discardPreviousDocuments(clusterUri);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add tests for this behavior? Specifically that closing the modal either through the X button or through the "Start new session" button discards previous documents, but opening a new regular modal does not discard them? This behavior requires a lot of things to be wired up correctly and I worry that it might be broken easily when refactoring either WorkspacesService, ModalsService, ModalsHost or how our modals work in general.

// The first one comes from restoring the previous session, but it is
// immediately canceled and replaced with a dialog to the cluster from
// the deep link.
await waitFor(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we wait here for the connect-cluster dialog to be opened with the correct content rather than doing waitFor for a mocked function? As in expect(screen.findByText(<whatever is shown in the modal>)). If possible, we shouldn't use toHaveBeenCalled as something that moves tests forward, but rather the actual results that the action has on the interface that the user sees.

* be changed to happen right before the modal is shown. Ultimately, the thing that interests us
* the most is whether the state from disk was loaded into memory. Maybe in the future we will
* need to separate values or an enum.
*
*/
isInitialized: boolean;
Copy link
Member

@ravicious ravicious Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a comment above this line in vnetContext.tsx:

isWorkspaceStateInitialized &&

which explains that accessing resources through VNet might trigger the MFA modal, so we have to wait for the tshd events service to be initialized.

await this.clustersService.syncRootClustersAndCatchErrors();
this.workspacesService.restorePersistedState();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I close the app while I'm in workspace A with a valid cert and open it again through a deep link to workspace B, I can close the login modal and continue to use workspace A without making a decision wrt restored state. The user should be forced to make an action before interacting with a workspace.

valid-cert-on-launch.mov

How do we fix this? I almost feel like DeepLinksService should always bail out to the "Connect" screen. As in, when loginAndSetActiveWorkspace is called, we check if the current workspace is equal to the workspace from the URL. If not, then we always zero rootClusterUri in WorkspacesService to force the user back to cluster choice. Otherwise, I feel it kind of makes little sense to launch the app through a link to workspace B, interact with a modal about workspace B (the login modal), then close it and be back at workspace A, just because it was the last used workspace before the app was closed.

I'm just not sure at this point how this is going to interact with the rest of the app.

* be changed to happen right before the modal is shown. Ultimately, the thing that interests us
* the most is whether the state from disk was loaded into memory. Maybe in the future we will
* need to separate values or an enum.
*
*/
isInitialized: boolean;
Copy link
Member

@ravicious ravicious Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed on the call, move setting this field out of restorePersistedState into a separate method that's called from pullInitialState.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
backport/branch/v16 backport/branch/v17 no-changelog Indicates that a PR does not require a changelog entry size/md ui
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Connect tries to login to previous session instead of logging in to the DeviceWebToken's session
3 participants