Skip to content

Commit c024db9

Browse files
authored
Improve credential management: clear on logout, add switch and manage commands (#768)
- Logout now clears stored credentials for the current deployment - Add "Switch Deployment" command to change deployments without clearing credentials - Add "Manage Credentials" command to view and remove stored credentials Fixes #760
1 parent 806b0ca commit c024db9

File tree

3 files changed

+132
-49
lines changed

3 files changed

+132
-49
lines changed

package.json

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,12 @@
217217
"category": "Coder",
218218
"icon": "$(sign-out)"
219219
},
220+
{
221+
"command": "coder.switchDeployment",
222+
"title": "Switch Deployment",
223+
"category": "Coder",
224+
"icon": "$(arrow-swap)"
225+
},
220226
{
221227
"command": "coder.open",
222228
"title": "Open Workspace",
@@ -282,9 +288,9 @@
282288
"icon": "$(search)"
283289
},
284290
{
285-
"command": "coder.debug.listDeployments",
286-
"title": "List Stored Deployments",
287-
"category": "Coder Debug"
291+
"command": "coder.manageCredentials",
292+
"title": "Manage Credentials",
293+
"category": "Coder"
288294
}
289295
],
290296
"menus": {
@@ -297,6 +303,10 @@
297303
"command": "coder.logout",
298304
"when": "coder.authenticated"
299305
},
306+
{
307+
"command": "coder.switchDeployment",
308+
"when": "coder.authenticated"
309+
},
300310
{
301311
"command": "coder.createWorkspace",
302312
"when": "coder.authenticated"
@@ -342,15 +352,18 @@
342352
"when": "false"
343353
},
344354
{
345-
"command": "coder.debug.listDeployments",
346-
"when": "coder.devMode"
355+
"command": "coder.manageCredentials"
347356
}
348357
],
349358
"view/title": [
350359
{
351360
"command": "coder.logout",
352361
"when": "coder.authenticated && view == myWorkspaces"
353362
},
363+
{
364+
"command": "coder.switchDeployment",
365+
"when": "coder.authenticated && view == myWorkspaces"
366+
},
354367
{
355368
"command": "coder.login",
356369
"when": "!coder.authenticated && view == myWorkspaces"

src/commands.ts

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import { type CoderApi } from "./api/coderApi";
1111
import { getGlobalFlags } from "./cliConfig";
1212
import { type CliManager } from "./core/cliManager";
1313
import { type ServiceContainer } from "./core/container";
14-
import { type ContextManager } from "./core/contextManager";
1514
import { type MementoManager } from "./core/mementoManager";
1615
import { type PathResolver } from "./core/pathResolver";
1716
import { type SecretsManager } from "./core/secretsManager";
1817
import { type DeploymentManager } from "./deployment/deploymentManager";
1918
import { CertificateError } from "./error/certificateError";
19+
import { toError } from "./error/errorUtils";
2020
import { type Logger } from "./logging/logger";
2121
import { type LoginCoordinator } from "./login/loginCoordinator";
2222
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
@@ -34,7 +34,6 @@ export class Commands {
3434
private readonly mementoManager: MementoManager;
3535
private readonly secretsManager: SecretsManager;
3636
private readonly cliManager: CliManager;
37-
private readonly contextManager: ContextManager;
3837
private readonly loginCoordinator: LoginCoordinator;
3938

4039
// These will only be populated when actively connected to a workspace and are
@@ -58,7 +57,6 @@ export class Commands {
5857
this.mementoManager = serviceContainer.getMementoManager();
5958
this.secretsManager = serviceContainer.getSecretsManager();
6059
this.cliManager = serviceContainer.getCliManager();
61-
this.contextManager = serviceContainer.getContextManager();
6260
this.loginCoordinator = serviceContainer.getLoginCoordinator();
6361
}
6462

@@ -74,9 +72,8 @@ export class Commands {
7472
}
7573

7674
/**
77-
* Log into the provided deployment. If the deployment URL is not specified,
78-
* ask for it first with a menu showing recent URLs along with the default URL
79-
* and CODER_URL, if those are set.
75+
* Log into a deployment. If already authenticated, this is a no-op.
76+
* If no URL is provided, shows a menu of recent URLs plus defaults.
8077
*/
8178
public async login(args?: {
8279
url?: string;
@@ -85,6 +82,13 @@ export class Commands {
8582
if (this.deploymentManager.isAuthenticated()) {
8683
return;
8784
}
85+
await this.performLogin(args);
86+
}
87+
88+
private async performLogin(args?: {
89+
url?: string;
90+
autoLogin?: boolean;
91+
}): Promise<void> {
8892
this.logger.debug("Logging in");
8993

9094
const currentDeployment = await this.secretsManager.getCurrentDeployment();
@@ -197,7 +201,7 @@ export class Commands {
197201
}
198202

199203
/**
200-
* Log out from the currently logged-in deployment.
204+
* Log out and clear stored credentials, requiring re-authentication on next login.
201205
*/
202206
public async logout(): Promise<void> {
203207
if (!this.deploymentManager.isAuthenticated()) {
@@ -206,8 +210,15 @@ export class Commands {
206210

207211
this.logger.debug("Logging out");
208212

213+
const safeHostname =
214+
this.deploymentManager.getCurrentDeployment()?.safeHostname;
215+
209216
await this.deploymentManager.clearDeployment();
210217

218+
if (safeHostname) {
219+
await this.secretsManager.clearAllAuthData(safeHostname);
220+
}
221+
211222
vscode.window
212223
.showInformationMessage("You've been logged out of Coder!", "Login")
213224
.then((action) => {
@@ -221,6 +232,95 @@ export class Commands {
221232
this.logger.debug("Logout complete");
222233
}
223234

235+
/**
236+
* Switch to a different deployment without clearing credentials.
237+
* If login fails or user cancels, stays on current deployment.
238+
*/
239+
public async switchDeployment(): Promise<void> {
240+
this.logger.debug("Switching deployment");
241+
await this.performLogin();
242+
}
243+
244+
/**
245+
* Manage stored credentials for all deployments.
246+
* Shows a list of deployments with options to remove individual or all credentials.
247+
*/
248+
public async manageCredentials(): Promise<void> {
249+
try {
250+
const hostnames = await this.secretsManager.getKnownSafeHostnames();
251+
if (hostnames.length === 0) {
252+
vscode.window.showInformationMessage("No stored credentials.");
253+
return;
254+
}
255+
256+
const items: Array<{
257+
label: string;
258+
description: string;
259+
hostnames: string[];
260+
}> = hostnames.map((hostname) => ({
261+
label: `$(key) ${hostname}`,
262+
description: "Remove stored credentials",
263+
hostnames: [hostname],
264+
}));
265+
266+
// Only show "Remove All" when there are multiple deployments
267+
if (hostnames.length > 1) {
268+
items.push({
269+
label: "$(trash) Remove All",
270+
description: `Remove credentials for all ${hostnames.length} deployments`,
271+
hostnames,
272+
});
273+
}
274+
275+
const selected = await vscode.window.showQuickPick(items, {
276+
title: "Manage Stored Credentials",
277+
placeHolder: "Select a deployment to remove",
278+
});
279+
280+
if (!selected) {
281+
return;
282+
}
283+
284+
if (selected.hostnames.length === 1) {
285+
const selectedHostname = selected.hostnames[0];
286+
await this.secretsManager.clearAllAuthData(selectedHostname);
287+
this.logger.info("Removed credentials for", selectedHostname);
288+
vscode.window.showInformationMessage(
289+
`Removed credentials for ${selectedHostname}`,
290+
);
291+
} else {
292+
const confirm = await vscodeProposed.window.showWarningMessage(
293+
`Remove ${selected.hostnames.length} Credentials`,
294+
{
295+
useCustom: true,
296+
modal: true,
297+
detail: `This will remove credentials for: ${selected.hostnames.join(", ")}\n\nYou'll need to log in again to access them.`,
298+
},
299+
"Remove All",
300+
);
301+
if (confirm === "Remove All") {
302+
await Promise.all(
303+
selected.hostnames.map((h) =>
304+
this.secretsManager.clearAllAuthData(h),
305+
),
306+
);
307+
this.logger.info(
308+
"Removed credentials for all deployments:",
309+
selected.hostnames.join(", "),
310+
);
311+
vscode.window.showInformationMessage(
312+
"Removed credentials for all deployments",
313+
);
314+
}
315+
}
316+
} catch (error: unknown) {
317+
this.logger.error("Failed to manage stored credentials", error);
318+
vscode.window.showErrorMessage(
319+
`Failed to manage stored credentials: ${toError(error).message}`,
320+
);
321+
}
322+
}
323+
224324
/**
225325
* Create a new workspace for the currently logged-in deployment.
226326
*

src/extension.ts

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
197197
"coder.logout",
198198
commands.logout.bind(commands),
199199
),
200+
vscode.commands.registerCommand(
201+
"coder.switchDeployment",
202+
commands.switchDeployment.bind(commands),
203+
),
200204
vscode.commands.registerCommand("coder.open", commands.open.bind(commands)),
201205
vscode.commands.registerCommand(
202206
"coder.openDevContainer",
@@ -240,8 +244,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
240244
vscode.commands.registerCommand("coder.searchAllWorkspaces", async () =>
241245
showTreeViewSearch(ALL_WORKSPACES_TREE_ID),
242246
),
243-
vscode.commands.registerCommand("coder.debug.listDeployments", () =>
244-
listStoredDeployments(serviceContainer),
247+
vscode.commands.registerCommand(
248+
"coder.manageCredentials",
249+
commands.manageCredentials.bind(commands),
245250
),
246251
);
247252

@@ -383,38 +388,3 @@ async function showTreeViewSearch(id: string): Promise<void> {
383388
await vscode.commands.executeCommand(`${id}.focus`);
384389
await vscode.commands.executeCommand("list.find");
385390
}
386-
387-
async function listStoredDeployments(
388-
serviceContainer: ServiceContainer,
389-
): Promise<void> {
390-
const secretsManager = serviceContainer.getSecretsManager();
391-
const output = serviceContainer.getLogger();
392-
393-
try {
394-
const hostnames = await secretsManager.getKnownSafeHostnames();
395-
if (hostnames.length === 0) {
396-
vscode.window.showInformationMessage("No deployments stored.");
397-
return;
398-
}
399-
400-
const selected = await vscode.window.showQuickPick(
401-
hostnames.map((hostname) => ({
402-
label: hostname,
403-
description: "Click to forget",
404-
})),
405-
{ placeHolder: "Select a deployment to forget" },
406-
);
407-
408-
if (selected) {
409-
await secretsManager.clearAllAuthData(selected.label);
410-
vscode.window.showInformationMessage(
411-
`Cleared auth data for ${selected.label}`,
412-
);
413-
}
414-
} catch (error: unknown) {
415-
output.error("Failed to list stored deployments", error);
416-
vscode.window.showErrorMessage(
417-
"Failed to list stored deployments. Storage may be corrupted.",
418-
);
419-
}
420-
}

0 commit comments

Comments
 (0)