Skip to content

Commit 7f7cb74

Browse files
committed
Unify login experience
1 parent 85f0290 commit 7f7cb74

File tree

9 files changed

+318
-285
lines changed

9 files changed

+318
-285
lines changed

src/commands.ts

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper";
99
import { type CliManager } from "./core/cliManager";
1010
import { type ServiceContainer } from "./core/container";
1111
import { type ContextManager } from "./core/contextManager";
12+
import { type Deployment } from "./core/deployment";
1213
import { type MementoManager } from "./core/mementoManager";
1314
import { type PathResolver } from "./core/pathResolver";
1415
import { type SecretsManager } from "./core/secretsManager";
@@ -61,6 +62,17 @@ export class Commands {
6162
this.loginCoordinator = serviceContainer.getLoginCoordinator();
6263
}
6364

65+
/**
66+
* Get the current deployment, throwing if not logged in.
67+
*/
68+
private async requireDeployment(): Promise<Deployment> {
69+
const deployment = await this.secretsManager.getCurrentDeployment();
70+
if (!deployment) {
71+
throw new Error("You are not logged in");
72+
}
73+
return deployment;
74+
}
75+
6476
/**
6577
* Log into the provided deployment. If the deployment URL is not specified,
6678
* ask for it first with a menu showing recent URLs along with the default URL
@@ -76,7 +88,12 @@ export class Commands {
7688
}
7789
this.logger.info("Logging in");
7890

79-
const url = await maybeAskUrl(this.mementoManager, args?.url);
91+
const currentDeployment = await this.secretsManager.getCurrentDeployment();
92+
const url = await maybeAskUrl(
93+
this.mementoManager,
94+
args?.url,
95+
currentDeployment?.url,
96+
);
8097
if (!url) {
8198
return;
8299
}
@@ -98,16 +115,13 @@ export class Commands {
98115
return;
99116
}
100117

101-
// Authorize the global client
118+
// Set client immediately so subsequent operations in this function have the correct host/token.
119+
// The cross-window listener will also update the client, but that's async.
102120
this.restClient.setHost(url);
103121
this.restClient.setSessionToken(result.token);
104122

105-
// Store for later sessions
106-
await this.mementoManager.setUrl(url);
107-
await this.secretsManager.setSessionAuth(label, {
108-
url,
109-
token: result.token,
110-
});
123+
// Set as current deployment (triggers cross-window sync).
124+
await this.secretsManager.setCurrentDeployment({ url, label });
111125

112126
// Update contexts
113127
this.contextManager.set("coder.authenticated", true);
@@ -130,7 +144,6 @@ export class Commands {
130144
}
131145
});
132146

133-
await this.secretsManager.triggerLoginStateChange(label, "login");
134147
vscode.commands.executeCommand("coder.refreshWorkspaces");
135148
}
136149

@@ -163,13 +176,8 @@ export class Commands {
163176
* Log out from the currently logged-in deployment.
164177
*/
165178
public async logout(): Promise<void> {
166-
const url = this.mementoManager.getUrl();
167-
if (!url) {
168-
// Sanity check; command should not be available if no url.
169-
throw new Error("You are not logged in");
170-
}
171-
172-
await this.forceLogout(toSafeHost(url));
179+
const deployment = await this.requireDeployment();
180+
await this.forceLogout(deployment.label);
173181
}
174182

175183
public async forceLogout(label: string): Promise<void> {
@@ -178,8 +186,7 @@ export class Commands {
178186
}
179187
this.logger.info(`Logging out of deployment: ${label}`);
180188

181-
// Only clear REST client and UI context if logging out of current deployment
182-
// Fire and forget
189+
// Fire and forget OAuth logout
183190
this.oauthSessionManager.logout().catch((error) => {
184191
this.logger.warn("OAuth logout failed, continuing with cleanup:", error);
185192
});
@@ -189,8 +196,10 @@ export class Commands {
189196
this.restClient.setHost("");
190197
this.restClient.setSessionToken("");
191198

192-
// Clear from memory.
193-
await this.mementoManager.setUrl(undefined);
199+
// Clear current deployment (triggers cross-window sync)
200+
await this.secretsManager.setCurrentDeployment(undefined);
201+
202+
// Clear all auth data for this deployment
194203
await this.secretsManager.clearAllAuthData(label);
195204

196205
this.contextManager.set("coder.authenticated", false);
@@ -204,8 +213,6 @@ export class Commands {
204213

205214
// This will result in clearing the workspace list.
206215
vscode.commands.executeCommand("coder.refreshWorkspaces");
207-
208-
await this.secretsManager.triggerLoginStateChange(label, "logout");
209216
}
210217

211218
/**
@@ -214,7 +221,8 @@ export class Commands {
214221
* Must only be called if currently logged in.
215222
*/
216223
public async createWorkspace(): Promise<void> {
217-
const uri = this.mementoManager.getUrl() + "/templates";
224+
const deployment = await this.requireDeployment();
225+
const uri = deployment.url + "/templates";
218226
await vscode.commands.executeCommand("vscode.open", uri);
219227
}
220228

@@ -228,8 +236,9 @@ export class Commands {
228236
*/
229237
public async navigateToWorkspace(item: OpenableTreeItem) {
230238
if (item) {
239+
const deployment = await this.requireDeployment();
231240
const workspaceId = createWorkspaceIdentifier(item.workspace);
232-
const uri = this.mementoManager.getUrl() + `/@${workspaceId}`;
241+
const uri = deployment.url + `/@${workspaceId}`;
233242
await vscode.commands.executeCommand("vscode.open", uri);
234243
} else if (this.workspace && this.workspaceRestClient) {
235244
const baseUrl =
@@ -251,8 +260,9 @@ export class Commands {
251260
*/
252261
public async navigateToWorkspaceSettings(item: OpenableTreeItem) {
253262
if (item) {
263+
const deployment = await this.requireDeployment();
254264
const workspaceId = createWorkspaceIdentifier(item.workspace);
255-
const uri = this.mementoManager.getUrl() + `/@${workspaceId}/settings`;
265+
const uri = deployment.url + `/@${workspaceId}/settings`;
256266
await vscode.commands.executeCommand("vscode.open", uri);
257267
} else if (this.workspace && this.workspaceRestClient) {
258268
const baseUrl =
@@ -329,18 +339,14 @@ export class Commands {
329339
const terminal = vscode.window.createTerminal(app.name);
330340

331341
// If workspace_name is provided, run coder ssh before the command
332-
333-
const url = this.mementoManager.getUrl();
334-
if (!url) {
335-
throw new Error("No coder url found for sidebar");
336-
}
342+
const deployment = await this.requireDeployment();
337343
const binary = await this.cliManager.fetchBinary(
338344
this.restClient,
339-
toSafeHost(url),
345+
deployment.label,
340346
);
341347

342348
const configDir = this.pathResolver.getGlobalConfigDir(
343-
toSafeHost(url),
349+
deployment.label,
344350
);
345351
const globalFlags = getGlobalFlags(
346352
vscode.workspace.getConfiguration(),

src/core/mementoManager.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,16 @@ export class MementoManager {
77
constructor(private readonly memento: Memento) {}
88

99
/**
10-
* Add the URL to the list of recently accessed URLs in global storage, then
11-
* set it as the last used URL.
12-
*
13-
* If the URL is falsey, then remove it as the last used URL and do not touch
14-
* the history.
10+
* Add a URL to the history of recently accessed URLs.
11+
* Used by the URL picker to show recent deployments.
1512
*/
16-
public async setUrl(url: string | undefined): Promise<void> {
17-
await this.memento.update("url", url);
13+
public async addToUrlHistory(url: string): Promise<void> {
1814
if (url) {
1915
const history = this.withUrlHistory(url);
2016
await this.memento.update("urlHistory", history);
2117
}
2218
}
2319

24-
/**
25-
* Get the last used URL.
26-
*/
27-
public getUrl(): string | undefined {
28-
return this.memento.get("url");
29-
}
30-
3120
/**
3221
* Get the most recently accessed URLs (oldest to newest) with the provided
3322
* values appended. Duplicates will be removed.

src/core/secretsManager.ts

Lines changed: 67 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import {
2-
type TokenResponse,
3-
type ClientRegistrationResponse,
4-
} from "../oauth/types";
1+
import { toSafeHost } from "../util";
52

63
import type { Memento, SecretStorage, Disposable } from "vscode";
74

5+
import type { TokenResponse, ClientRegistrationResponse } from "../oauth/types";
6+
7+
import type { Deployment } from "./deployment";
8+
89
const SESSION_KEY_PREFIX = "coder.session.";
910
const OAUTH_TOKENS_PREFIX = "coder.oauth.tokens.";
1011
const OAUTH_CLIENT_PREFIX = "coder.oauth.client.";
1112

12-
const LOGIN_STATE_KEY = "coder.loginState";
13+
const CURRENT_DEPLOYMENT_KEY = "coder.currentDeployment";
1314
const OAUTH_CALLBACK_KEY = "coder.oauthCallback";
1415

1516
const KNOWN_LABELS_KEY = "coder.knownLabels";
@@ -32,10 +33,8 @@ interface OAuthCallbackData {
3233
error: string | null;
3334
}
3435

35-
export enum AuthAction {
36-
LOGIN,
37-
LOGOUT,
38-
INVALID,
36+
export interface CurrentDeploymentState {
37+
deployment: Deployment | null;
3938
}
4039

4140
export class SecretsManager {
@@ -45,54 +44,57 @@ export class SecretsManager {
4544
) {}
4645

4746
/**
48-
* Triggers a login/logout event that propagates across all VS Code windows.
47+
* Sets the current deployment and triggers a cross-window sync event.
48+
* This is the single source of truth for which deployment is currently active.
4949
*/
50-
public async triggerLoginStateChange(
51-
label: string,
52-
action: "login" | "logout",
50+
public async setCurrentDeployment(
51+
deployment: Deployment | undefined,
5352
): Promise<void> {
54-
const loginState = {
55-
action,
56-
label,
53+
const state = {
54+
deployment: deployment ?? null,
5755
timestamp: new Date().toISOString(),
5856
};
59-
await this.secrets.store(LOGIN_STATE_KEY, JSON.stringify(loginState));
57+
await this.secrets.store(CURRENT_DEPLOYMENT_KEY, JSON.stringify(state));
6058
}
6159

6260
/**
63-
* Listens for login/logout events from any VS Code window.
61+
* Gets the current deployment from storage.
6462
*/
65-
public onDidChangeLoginState(
66-
listener: (state: AuthAction, label: string) => Promise<void>,
67-
): Disposable {
68-
return this.secrets.onDidChange(async (e) => {
69-
if (e.key !== LOGIN_STATE_KEY) {
70-
return;
63+
public async getCurrentDeployment(): Promise<Deployment | undefined> {
64+
try {
65+
const data = await this.secrets.get(CURRENT_DEPLOYMENT_KEY);
66+
if (!data) {
67+
return undefined;
7168
}
69+
const parsed = JSON.parse(data) as { deployment: Deployment | null };
70+
return parsed.deployment ?? undefined;
71+
} catch {
72+
return undefined;
73+
}
74+
}
7275

73-
const stateStr = await this.secrets.get(LOGIN_STATE_KEY);
74-
if (!stateStr) {
75-
await listener(AuthAction.INVALID, "");
76+
/**
77+
* Listens for deployment changes from any VS Code window.
78+
* Fires when login, logout, or deployment switch occurs.
79+
*/
80+
public onDidChangeCurrentDeployment(
81+
listener: (state: CurrentDeploymentState) => void | Promise<void>,
82+
): Disposable {
83+
return this.secrets.onDidChange(async (e) => {
84+
if (e.key !== CURRENT_DEPLOYMENT_KEY) {
7685
return;
7786
}
7887

7988
try {
80-
const parsed = JSON.parse(stateStr) as {
81-
action: string;
82-
label: string;
83-
timestamp: string;
84-
};
85-
86-
if (parsed.action === "login") {
87-
await listener(AuthAction.LOGIN, parsed.label);
88-
} else if (parsed.action === "logout") {
89-
await listener(AuthAction.LOGOUT, parsed.label);
90-
} else {
91-
await listener(AuthAction.INVALID, parsed.label);
89+
const data = await this.secrets.get(CURRENT_DEPLOYMENT_KEY);
90+
if (data) {
91+
const parsed = JSON.parse(data) as {
92+
deployment: Deployment | null;
93+
};
94+
await listener({ deployment: parsed.deployment });
9295
}
9396
} catch {
94-
// Invalid JSON, treat as invalid state
95-
await listener(AuthAction.INVALID, "");
97+
// Ignore parse errors
9698
}
9799
});
98100
}
@@ -132,7 +134,7 @@ export class SecretsManager {
132134
/**
133135
* Listen for changes to a specific deployment's session auth.
134136
*/
135-
public onDidChangeDeploymentAuth(
137+
public onDidChangeSessionAuth(
136138
label: string,
137139
listener: (auth: SessionAuth | undefined) => void | Promise<void>,
138140
): Disposable {
@@ -147,6 +149,10 @@ export class SecretsManager {
147149
}
148150

149151
public async getSessionAuth(label: string): Promise<SessionAuth | undefined> {
152+
if (!label) {
153+
return undefined;
154+
}
155+
150156
try {
151157
const data = await this.secrets.get(`${SESSION_KEY_PREFIX}${label}`);
152158
if (!data) {
@@ -286,24 +292,35 @@ export class SecretsManager {
286292

287293
/**
288294
* Migrate from legacy flat sessionToken storage to new format.
295+
* Also sets the current deployment if none exists.
289296
*/
290-
public async migrateFromLegacyStorage(
291-
url: string,
292-
label: string,
293-
): Promise<boolean> {
297+
public async migrateFromLegacyStorage(): Promise<string | undefined> {
298+
const legacyUrl = this.memento.get<string>("url");
299+
if (!legacyUrl) {
300+
return undefined;
301+
}
302+
303+
const label = toSafeHost(legacyUrl);
304+
294305
const existing = await this.getSessionAuth(label);
295306
if (existing) {
296-
return false;
307+
return undefined;
297308
}
298309

299310
const oldToken = await this.secrets.get(LEGACY_SESSION_TOKEN_KEY);
300311
if (!oldToken) {
301-
return false;
312+
return undefined;
302313
}
303314

304-
await this.setSessionAuth(label, { url, token: oldToken });
315+
await this.setSessionAuth(label, { url: legacyUrl, token: oldToken });
305316
await this.secrets.delete(LEGACY_SESSION_TOKEN_KEY);
306317

307-
return true;
318+
// Also set as current deployment if none exists
319+
const currentDeployment = await this.getCurrentDeployment();
320+
if (!currentDeployment) {
321+
await this.setCurrentDeployment({ url: legacyUrl, label });
322+
}
323+
324+
return label;
308325
}
309326
}

0 commit comments

Comments
 (0)