wraith/src/components/connections/ConnectionEditDialog.vue
Vantz Stockwell f825692ecc
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m54s
feat: SSH key file browser — Browse button to load keys from disk
Adds a file picker next to the Private Key textarea in the credential
dialog. Users can browse for key files (.pem, .key, id_rsa, id_ed25519,
etc.) instead of copy-pasting PEM content. Uses standard FileReader API
— no additional Tauri plugins needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:20:00 -04:00

629 lines
24 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.credentialType === '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>
<div class="flex gap-2 mb-1">
<button
type="button"
class="px-3 py-1.5 text-xs rounded bg-[#21262d] border border-[#30363d] text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] hover:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
@click="browseKeyFile"
>
Browse...
</button>
<span v-if="keyFileName" class="text-xs text-[var(--wraith-text-muted)] self-center truncate">{{ keyFileName }}</span>
</div>
<input
ref="keyFileInputRef"
type="file"
class="hidden"
accept=".pem,.key,.pub,.id_rsa,.id_ed25519,.id_ecdsa,.ppk"
@change="loadKeyFile"
/>
<textarea
v-model="newCred.privateKeyPEM"
rows="5"
placeholder="Paste key or use Browse to load from file"
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 { invoke } from "@tauri-apps/api/core";
import { useConnectionStore, type Connection } from "@/stores/connection.store";
interface Credential {
id: number;
name: string;
username: string | null;
domain: string | null;
credentialType: "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: "",
});
// SSH key file picker
const keyFileInputRef = ref<HTMLInputElement | null>(null);
const keyFileName = ref("");
function browseKeyFile(): void {
keyFileInputRef.value?.click();
}
function loadKeyFile(event: Event): void {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
keyFileName.value = file.name;
const reader = new FileReader();
reader.onload = () => {
newCred.value.privateKeyPEM = (reader.result as string).trim();
};
reader.readAsText(file);
}
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 = "";
keyFileName.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 invoke("delete_credential", { id: 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 invoke<Credential[]>("list_credentials");
credentials.value = result || [];
} catch {
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 invoke<Credential>("create_password", {
name: newCred.value.name.trim(),
username: newCred.value.username.trim(),
password: newCred.value.password,
domain: null,
});
} else {
// SSH Key
created = await invoke<Credential>("create_ssh_key", {
name: newCred.value.name.trim(),
username: newCred.value.username.trim(),
privateKeyPem: newCred.value.privateKeyPEM.trim(),
passphrase: newCred.value.passphrase || null,
});
}
// 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 invoke("update_connection", {
id: editingId.value,
input: {
name: form.value.name,
hostname: form.value.hostname,
port: form.value.port,
groupId: form.value.groupId,
credentialId: form.value.credentialId,
color: form.value.color || null,
tags,
notes: form.value.notes || null,
},
});
} else {
await invoke("create_connection", {
input: {
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 || null,
tags,
notes: form.value.notes || null,
},
});
}
// Refresh connections from backend
await connectionStore.loadAll();
close();
} catch (err) {
console.error("Failed to save connection:", err);
}
}
defineExpose({ openNew, openEdit, close, visible });
</script>