wraith/frontend/src/components/connections/ConnectionEditDialog.vue
Vantz Stockwell af629fa373
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m3s
fix: add username field to SSH key credential form — was missing entirely
Root cause of pubkey auth failure: SSH key credentials had no username,
so ConnectSSH defaulted to "root" and the server rejected the key.
The SSH key form in ConnectionEditDialog only had Name, PEM, Passphrase.
Added Username field between Name and PEM.

Delete your existing SSH key credentials and re-create them with the
correct username.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:15:08 -04:00

597 lines
23 KiB
Vue

<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center"
@click.self="close"
@keydown.esc="close"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="close" />
<!-- Dialog -->
<div class="relative w-full max-w-md bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">
{{ isEditing ? "Edit Connection" : "New Connection" }}
</h3>
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@click="close"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.749.749 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.749.749 0 1 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-4 py-4 space-y-3 max-h-[80vh] overflow-y-auto">
<!-- Name -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Name</label>
<input
v-model="form.name"
type="text"
placeholder="My Server"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Hostname & Port -->
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Hostname</label>
<input
v-model="form.hostname"
type="text"
placeholder="192.168.1.1"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div class="w-24">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Port</label>
<input
v-model.number="form.port"
type="number"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
</div>
<!-- Protocol -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Protocol</label>
<div class="flex gap-2">
<button
class="flex-1 py-2 text-sm rounded border transition-colors cursor-pointer"
:class="form.protocol === 'ssh'
? 'bg-[#3fb950]/10 border-[#3fb950] text-[#3fb950]'
: 'bg-[#0d1117] border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="setProtocol('ssh')"
>
SSH
</button>
<button
class="flex-1 py-2 text-sm rounded border transition-colors cursor-pointer"
:class="form.protocol === 'rdp'
? 'bg-[#1f6feb]/10 border-[#1f6feb] text-[#1f6feb]'
: 'bg-[#0d1117] border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="setProtocol('rdp')"
>
RDP
</button>
</div>
</div>
<!-- Group -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Group</label>
<select
v-model="form.groupId"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
>
<option :value="null">No Group</option>
<option v-for="group in connectionStore.groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
</div>
<!-- Tags -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Tags (comma-separated)</label>
<input
v-model="tagsInput"
type="text"
placeholder="Prod, Linux, Web"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Color -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Color Label</label>
<div class="flex gap-2">
<button
v-for="color in colorOptions"
:key="color.value"
class="w-6 h-6 rounded-full border-2 transition-transform cursor-pointer hover:scale-110"
:class="form.color === color.value ? 'border-white scale-110' : 'border-transparent'"
:style="{ backgroundColor: color.hex }"
:title="color.label"
@click="form.color = color.value"
/>
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Notes</label>
<textarea
v-model="form.notes"
rows="3"
placeholder="Optional notes about this connection..."
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none"
/>
</div>
<!-- Divider -->
<div class="border-t border-[#30363d]" />
<!-- Credentials -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Credential</label>
<div class="flex gap-2">
<select
v-model="form.credentialId"
class="flex-1 px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
>
<option :value="null">None</option>
<option
v-for="cred in credentials"
:key="cred.id"
:value="cred.id"
>
{{ cred.name }}
<template v-if="cred.type === 'ssh_key'"> (SSH Key)</template>
<template v-else> (Password{{ cred.username ? ` — ${cred.username}` : '' }})</template>
</option>
</select>
<button
v-if="form.credentialId"
type="button"
class="px-2.5 py-2 text-xs rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
title="Delete selected credential"
@click="deleteSelectedCredential"
>
Delete
</button>
</div>
</div>
<!-- Add New Credential (collapsible) -->
<div class="rounded border border-[#30363d] overflow-hidden">
<!-- Toggle header -->
<button
class="w-full flex items-center justify-between px-3 py-2 text-xs text-[var(--wraith-text-secondary)] hover:bg-[#1c2128] transition-colors cursor-pointer"
@click="showNewCred = !showNewCred"
>
<span class="font-medium">Add New Credential</span>
<svg
class="w-3.5 h-3.5 transition-transform"
:class="showNewCred ? 'rotate-180' : ''"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M4.427 7.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z" />
</svg>
</button>
<!-- Collapsed content -->
<div v-if="showNewCred" class="px-3 pb-3 pt-2 space-y-3 bg-[#0d1117]">
<!-- Credential type selector -->
<div class="flex gap-2">
<button
class="flex-1 py-1.5 text-xs rounded border transition-colors cursor-pointer"
:class="newCredType === 'password'
? 'bg-[#3fb950]/10 border-[#3fb950] text-[#3fb950]'
: 'bg-transparent border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="newCredType = 'password'"
>
Password
</button>
<button
class="flex-1 py-1.5 text-xs rounded border transition-colors cursor-pointer"
:class="newCredType === 'ssh_key'
? 'bg-[#58a6ff]/10 border-[#58a6ff] text-[#58a6ff]'
: 'bg-transparent border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="newCredType = 'ssh_key'"
>
SSH Key
</button>
</div>
<!-- Password fields -->
<template v-if="newCredType === 'password'">
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Credential Name</label>
<input
v-model="newCred.name"
type="text"
placeholder="e.g. prod-admin"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Username</label>
<input
v-model="newCred.username"
type="text"
placeholder="admin"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Password</label>
<input
v-model="newCred.password"
type="password"
placeholder="••••••••"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
</template>
<!-- SSH Key fields -->
<template v-if="newCredType === 'ssh_key'">
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Credential Name</label>
<input
v-model="newCred.name"
type="text"
placeholder="e.g. my-ssh-key"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Username</label>
<input
v-model="newCred.username"
type="text"
placeholder="e.g. vstockwell"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Private Key (PEM)</label>
<textarea
v-model="newCred.privateKeyPEM"
rows="5"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none font-mono"
spellcheck="false"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Passphrase <span class="text-[var(--wraith-text-muted)]">(optional)</span></label>
<input
v-model="newCred.passphrase"
type="password"
placeholder="Leave blank if key is unencrypted"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
</template>
<!-- Error message -->
<p v-if="newCredError" class="text-xs text-[#f85149]">{{ newCredError }}</p>
<!-- Save credential button -->
<button
class="w-full py-1.5 text-xs rounded border transition-colors cursor-pointer"
:class="isNewCredValid
? 'bg-[#238636] border-[#238636] text-white hover:bg-[#2ea043] hover:border-[#2ea043]'
: 'bg-transparent border-[#30363d] text-[var(--wraith-text-muted)] cursor-not-allowed opacity-50'
"
:disabled="!isNewCredValid || savingCred"
@click="saveNewCredential"
>
{{ savingCred ? 'Saving...' : 'Save Credential' }}
</button>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-[#30363d]">
<button
class="px-3 py-1.5 text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="close"
>
Cancel
</button>
<button
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer"
:class="{ 'opacity-50 cursor-not-allowed': !isValid }"
:disabled="!isValid"
@click="save"
>
{{ isEditing ? "Save Changes" : "Create" }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useConnectionStore, type Connection } from "@/stores/connection.store";
import { Call } from "@wailsio/runtime";
/** Fully qualified Go method name prefix for ConnectionService bindings. */
const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService";
/**
* Credential methods are proxied through WraithApp (registered at startup)
* because CredentialService is nil until vault unlock and can't be pre-registered.
*/
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
interface Credential {
id: number;
name: string;
username: string;
domain: string;
type: "password" | "ssh_key";
sshKeyId: number | null;
}
interface ConnectionForm {
name: string;
hostname: string;
port: number;
protocol: "ssh" | "rdp";
groupId: number | null;
credentialId: number | null;
color: string;
notes: string;
}
interface NewCredForm {
name: string;
username: string;
password: string;
privateKeyPEM: string;
passphrase: string;
}
const connectionStore = useConnectionStore();
const visible = ref(false);
const isEditing = ref(false);
const editingId = ref<number | null>(null);
const tagsInput = ref("");
const credentials = ref<Credential[]>([]);
// New credential panel state
const showNewCred = ref(false);
const newCredType = ref<"password" | "ssh_key">("password");
const savingCred = ref(false);
const newCredError = ref("");
const newCred = ref<NewCredForm>({
name: "",
username: "",
password: "",
privateKeyPEM: "",
passphrase: "",
});
const form = ref<ConnectionForm>({
name: "",
hostname: "",
port: 22,
protocol: "ssh",
groupId: null,
credentialId: null,
color: "",
notes: "",
});
const colorOptions = [
{ value: "", label: "None", hex: "#30363d" },
{ value: "red", label: "Red", hex: "#f85149" },
{ value: "orange", label: "Orange", hex: "#d29922" },
{ value: "green", label: "Green", hex: "#3fb950" },
{ value: "blue", label: "Blue", hex: "#58a6ff" },
{ value: "purple", label: "Purple", hex: "#bc8cff" },
{ value: "pink", label: "Pink", hex: "#f778ba" },
];
const isValid = computed(() => {
return form.value.name.trim() !== "" && form.value.hostname.trim() !== "" && form.value.port > 0;
});
const isNewCredValid = computed(() => {
if (!newCred.value.name.trim()) return false;
if (newCredType.value === "password") {
return newCred.value.password.length > 0;
}
// ssh_key: must have PEM content
return newCred.value.privateKeyPEM.trim().length > 0;
});
function setProtocol(protocol: "ssh" | "rdp"): void {
form.value.protocol = protocol;
if (protocol === "ssh" && form.value.port === 3389) {
form.value.port = 22;
} else if (protocol === "rdp" && form.value.port === 22) {
form.value.port = 3389;
}
}
function resetNewCredForm(): void {
newCred.value = { name: "", username: "", password: "", privateKeyPEM: "", passphrase: "" };
newCredError.value = "";
}
async function deleteSelectedCredential(): Promise<void> {
if (!form.value.credentialId) return;
const cred = credentials.value.find(c => c.id === form.value.credentialId);
const name = cred?.name ?? `ID ${form.value.credentialId}`;
if (!confirm(`Delete credential "${name}"? This cannot be undone.`)) return;
try {
await Call.ByName(`${APP}.DeleteCredential`, form.value.credentialId);
form.value.credentialId = null;
await loadCredentials();
} catch (err) {
alert(`Failed to delete credential: ${err}`);
}
}
async function loadCredentials(): Promise<void> {
try {
const result = await Call.ByName(`${APP}.ListCredentials`) as Credential[];
credentials.value = result || [];
} catch (err) {
credentials.value = [];
}
}
async function saveNewCredential(): Promise<void> {
if (!isNewCredValid.value || savingCred.value) return;
savingCred.value = true;
newCredError.value = "";
try {
let created: Credential | null = null;
if (newCredType.value === "password") {
created = await Call.ByName(
`${APP}.CreatePassword`,
newCred.value.name.trim(),
newCred.value.username.trim(),
newCred.value.password,
"", // domain — not collected in this form
) as Credential;
} else {
// SSH Key: CreateSSHKeyCredential(name, username, privateKeyPEM string, passphrase string)
created = await Call.ByName(
`${APP}.CreateSSHKeyCredential`,
newCred.value.name.trim(),
newCred.value.username.trim(),
newCred.value.privateKeyPEM.trim(),
newCred.value.passphrase,
) as Credential;
}
// Refresh list and auto-select the new credential
await loadCredentials();
if (created) {
form.value.credentialId = created.id;
}
resetNewCredForm();
showNewCred.value = false;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
newCredError.value = msg || "Failed to save credential.";
} finally {
savingCred.value = false;
}
}
function openNew(groupId?: number): void {
isEditing.value = false;
editingId.value = null;
form.value = {
name: "",
hostname: "",
port: 22,
protocol: "ssh",
groupId: groupId ?? null,
credentialId: null,
color: "",
notes: "",
};
tagsInput.value = "";
resetNewCredForm();
showNewCred.value = false;
void loadCredentials();
visible.value = true;
}
function openEdit(conn: Connection): void {
isEditing.value = true;
editingId.value = conn.id;
form.value = {
name: conn.name,
hostname: conn.hostname,
port: conn.port,
protocol: conn.protocol,
groupId: conn.groupId,
credentialId: conn.credentialId ?? null,
color: conn.color ?? "",
notes: conn.notes ?? "",
};
tagsInput.value = conn.tags?.join(", ") ?? "";
resetNewCredForm();
showNewCred.value = false;
void loadCredentials();
visible.value = true;
}
function close(): void {
visible.value = false;
}
async function save(): Promise<void> {
if (!isValid.value) return;
const tags = tagsInput.value
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
try {
if (isEditing.value && editingId.value !== null) {
await Call.ByName(`${CONN}.UpdateConnection`, editingId.value, {
name: form.value.name,
hostname: form.value.hostname,
port: form.value.port,
groupId: form.value.groupId,
credentialId: form.value.credentialId,
color: form.value.color,
tags,
notes: form.value.notes,
});
} else {
await Call.ByName(`${CONN}.CreateConnection`, {
name: form.value.name,
hostname: form.value.hostname,
port: form.value.port,
protocol: form.value.protocol,
groupId: form.value.groupId,
credentialId: form.value.credentialId,
color: form.value.color,
tags,
notes: form.value.notes,
});
}
// Refresh connections from backend
await connectionStore.loadAll();
close();
} catch (err) {
console.error("Failed to save connection:", err);
}
}
defineExpose({ openNew, openEdit, close, visible });
</script>