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
23 changes: 18 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand All @@ -297,6 +303,10 @@
"command": "coder.logout",
"when": "coder.authenticated"
},
{
"command": "coder.switchDeployment",
"when": "coder.authenticated"
},
{
"command": "coder.createWorkspace",
"when": "coder.authenticated"
Expand Down Expand Up @@ -342,15 +352,18 @@
"when": "false"
},
{
"command": "coder.debug.listDeployments",
"when": "coder.devMode"
"command": "coder.manageCredentials"
}
],
"view/title": [
{
"command": "coder.logout",
"when": "coder.authenticated && view == myWorkspaces"
},
{
"command": "coder.switchDeployment",
"when": "coder.authenticated && view == myWorkspaces"
},
{
"command": "coder.login",
"when": "!coder.authenticated && view == myWorkspaces"
Expand Down
114 changes: 107 additions & 7 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand All @@ -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();
}

Expand All @@ -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;
Expand All @@ -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<void> {
this.logger.debug("Logging in");

const currentDeployment = await this.secretsManager.getCurrentDeployment();
Expand Down Expand Up @@ -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<void> {
if (!this.deploymentManager.isAuthenticated()) {
Expand All @@ -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) => {
Expand All @@ -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<void> {
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<void> {
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.
*
Expand Down
44 changes: 7 additions & 37 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
"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",
Expand Down Expand Up @@ -240,8 +244,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
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),
),
);

Expand Down Expand Up @@ -383,38 +388,3 @@ async function showTreeViewSearch(id: string): Promise<void> {
await vscode.commands.executeCommand(`${id}.focus`);
await vscode.commands.executeCommand("list.find");
}

async function listStoredDeployments(
serviceContainer: ServiceContainer,
): Promise<void> {
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.",
);
}
}