diff --git a/package.json b/package.json index 818d42d3..e27320d9 100644 --- a/package.json +++ b/package.json @@ -217,6 +217,12 @@ "category": "Coder", "icon": "$(sign-out)" }, + { + "command": "coder.switchDeployment", + "title": "Switch Deployment", + "category": "Coder", + "icon": "$(arrow-swap)" + }, { "command": "coder.open", "title": "Open Workspace", @@ -282,9 +288,9 @@ "icon": "$(search)" }, { - "command": "coder.debug.listDeployments", - "title": "List Stored Deployments", - "category": "Coder Debug" + "command": "coder.manageCredentials", + "title": "Manage Credentials", + "category": "Coder" } ], "menus": { @@ -297,6 +303,10 @@ "command": "coder.logout", "when": "coder.authenticated" }, + { + "command": "coder.switchDeployment", + "when": "coder.authenticated" + }, { "command": "coder.createWorkspace", "when": "coder.authenticated" @@ -342,8 +352,7 @@ "when": "false" }, { - "command": "coder.debug.listDeployments", - "when": "coder.devMode" + "command": "coder.manageCredentials" } ], "view/title": [ @@ -351,6 +360,10 @@ "command": "coder.logout", "when": "coder.authenticated && view == myWorkspaces" }, + { + "command": "coder.switchDeployment", + "when": "coder.authenticated && view == myWorkspaces" + }, { "command": "coder.login", "when": "!coder.authenticated && view == myWorkspaces" diff --git a/src/commands.ts b/src/commands.ts index 23b49b19..738695e6 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -11,12 +11,12 @@ import { type CoderApi } from "./api/coderApi"; import { getGlobalFlags } from "./cliConfig"; import { type CliManager } from "./core/cliManager"; import { type ServiceContainer } from "./core/container"; -import { type ContextManager } from "./core/contextManager"; import { type MementoManager } from "./core/mementoManager"; import { type PathResolver } from "./core/pathResolver"; import { type SecretsManager } from "./core/secretsManager"; import { type DeploymentManager } from "./deployment/deploymentManager"; import { CertificateError } from "./error/certificateError"; +import { toError } from "./error/errorUtils"; import { type Logger } from "./logging/logger"; import { type LoginCoordinator } from "./login/loginCoordinator"; import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; @@ -34,7 +34,6 @@ export class Commands { private readonly mementoManager: MementoManager; private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; - private readonly contextManager: ContextManager; private readonly loginCoordinator: LoginCoordinator; // These will only be populated when actively connected to a workspace and are @@ -58,7 +57,6 @@ export class Commands { this.mementoManager = serviceContainer.getMementoManager(); this.secretsManager = serviceContainer.getSecretsManager(); this.cliManager = serviceContainer.getCliManager(); - this.contextManager = serviceContainer.getContextManager(); this.loginCoordinator = serviceContainer.getLoginCoordinator(); } @@ -74,9 +72,8 @@ export class Commands { } /** - * Log into the provided deployment. If the deployment URL is not specified, - * ask for it first with a menu showing recent URLs along with the default URL - * and CODER_URL, if those are set. + * Log into a deployment. If already authenticated, this is a no-op. + * If no URL is provided, shows a menu of recent URLs plus defaults. */ public async login(args?: { url?: string; @@ -85,6 +82,13 @@ export class Commands { if (this.deploymentManager.isAuthenticated()) { return; } + await this.performLogin(args); + } + + private async performLogin(args?: { + url?: string; + autoLogin?: boolean; + }): Promise { this.logger.debug("Logging in"); const currentDeployment = await this.secretsManager.getCurrentDeployment(); @@ -197,7 +201,7 @@ export class Commands { } /** - * Log out from the currently logged-in deployment. + * Log out and clear stored credentials, requiring re-authentication on next login. */ public async logout(): Promise { if (!this.deploymentManager.isAuthenticated()) { @@ -206,8 +210,15 @@ export class Commands { this.logger.debug("Logging out"); + const safeHostname = + this.deploymentManager.getCurrentDeployment()?.safeHostname; + await this.deploymentManager.clearDeployment(); + if (safeHostname) { + await this.secretsManager.clearAllAuthData(safeHostname); + } + vscode.window .showInformationMessage("You've been logged out of Coder!", "Login") .then((action) => { @@ -221,6 +232,95 @@ export class Commands { this.logger.debug("Logout complete"); } + /** + * Switch to a different deployment without clearing credentials. + * If login fails or user cancels, stays on current deployment. + */ + public async switchDeployment(): Promise { + this.logger.debug("Switching deployment"); + await this.performLogin(); + } + + /** + * Manage stored credentials for all deployments. + * Shows a list of deployments with options to remove individual or all credentials. + */ + public async manageCredentials(): Promise { + try { + const hostnames = await this.secretsManager.getKnownSafeHostnames(); + if (hostnames.length === 0) { + vscode.window.showInformationMessage("No stored credentials."); + return; + } + + const items: Array<{ + label: string; + description: string; + hostnames: string[]; + }> = hostnames.map((hostname) => ({ + label: `$(key) ${hostname}`, + description: "Remove stored credentials", + hostnames: [hostname], + })); + + // Only show "Remove All" when there are multiple deployments + if (hostnames.length > 1) { + items.push({ + label: "$(trash) Remove All", + description: `Remove credentials for all ${hostnames.length} deployments`, + hostnames, + }); + } + + const selected = await vscode.window.showQuickPick(items, { + title: "Manage Stored Credentials", + placeHolder: "Select a deployment to remove", + }); + + if (!selected) { + return; + } + + if (selected.hostnames.length === 1) { + const selectedHostname = selected.hostnames[0]; + await this.secretsManager.clearAllAuthData(selectedHostname); + this.logger.info("Removed credentials for", selectedHostname); + vscode.window.showInformationMessage( + `Removed credentials for ${selectedHostname}`, + ); + } else { + const confirm = await vscodeProposed.window.showWarningMessage( + `Remove ${selected.hostnames.length} Credentials`, + { + useCustom: true, + modal: true, + detail: `This will remove credentials for: ${selected.hostnames.join(", ")}\n\nYou'll need to log in again to access them.`, + }, + "Remove All", + ); + if (confirm === "Remove All") { + await Promise.all( + selected.hostnames.map((h) => + this.secretsManager.clearAllAuthData(h), + ), + ); + this.logger.info( + "Removed credentials for all deployments:", + selected.hostnames.join(", "), + ); + vscode.window.showInformationMessage( + "Removed credentials for all deployments", + ); + } + } + } catch (error: unknown) { + this.logger.error("Failed to manage stored credentials", error); + vscode.window.showErrorMessage( + `Failed to manage stored credentials: ${toError(error).message}`, + ); + } + } + /** * Create a new workspace for the currently logged-in deployment. * diff --git a/src/extension.ts b/src/extension.ts index 10902b3c..8702ea52 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -197,6 +197,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { "coder.logout", commands.logout.bind(commands), ), + vscode.commands.registerCommand( + "coder.switchDeployment", + commands.switchDeployment.bind(commands), + ), vscode.commands.registerCommand("coder.open", commands.open.bind(commands)), vscode.commands.registerCommand( "coder.openDevContainer", @@ -240,8 +244,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.registerCommand("coder.searchAllWorkspaces", async () => showTreeViewSearch(ALL_WORKSPACES_TREE_ID), ), - vscode.commands.registerCommand("coder.debug.listDeployments", () => - listStoredDeployments(serviceContainer), + vscode.commands.registerCommand( + "coder.manageCredentials", + commands.manageCredentials.bind(commands), ), ); @@ -383,38 +388,3 @@ async function showTreeViewSearch(id: string): Promise { await vscode.commands.executeCommand(`${id}.focus`); await vscode.commands.executeCommand("list.find"); } - -async function listStoredDeployments( - serviceContainer: ServiceContainer, -): Promise { - const secretsManager = serviceContainer.getSecretsManager(); - const output = serviceContainer.getLogger(); - - try { - const hostnames = await secretsManager.getKnownSafeHostnames(); - if (hostnames.length === 0) { - vscode.window.showInformationMessage("No deployments stored."); - return; - } - - const selected = await vscode.window.showQuickPick( - hostnames.map((hostname) => ({ - label: hostname, - description: "Click to forget", - })), - { placeHolder: "Select a deployment to forget" }, - ); - - if (selected) { - await secretsManager.clearAllAuthData(selected.label); - vscode.window.showInformationMessage( - `Cleared auth data for ${selected.label}`, - ); - } - } catch (error: unknown) { - output.error("Failed to list stored deployments", error); - vscode.window.showErrorMessage( - "Failed to list stored deployments. Storage may be corrupted.", - ); - } -}