All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m54s
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>
629 lines
24 KiB
Vue
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>
|