fix: wire vault persistence, connection loading, and MobaXterm import to real Go backend

Replace all TODO stubs in frontend stores with real Wails Call.ByName
bindings. The app store now calls WraithApp.IsFirstRun/CreateVault/Unlock
so vault state persists across launches. The connection store loads from
ConnectionService.ListConnections/ListGroups instead of hardcoded mock
data. The import dialog calls a new WraithApp.ImportMobaConf method that
parses the file, creates groups and connections in SQLite, and stores
host keys. ConnectionEditDialog also uses real Go CRUD calls. MainLayout
loads connections on mount after vault unlock.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 10:27:50 -04:00
parent fbd2fd4f80
commit a1dce82d99
6 changed files with 183 additions and 69 deletions

View File

@ -88,6 +88,13 @@
<p class="text-xs text-[var(--wraith-text-muted)]"> <p class="text-xs text-[var(--wraith-text-muted)]">
Passwords are not imported (MobaXTerm uses proprietary encryption). Passwords are not imported (MobaXTerm uses proprietary encryption).
</p> </p>
<div
v-if="importError"
class="text-sm text-[var(--wraith-accent-red)] bg-[var(--wraith-accent-red)]/10 border border-[var(--wraith-accent-red)]/20 rounded px-3 py-2"
>
{{ importError }}
</div>
</div> </div>
</template> </template>
@ -116,10 +123,11 @@
</button> </button>
<button <button
v-if="step === 'preview'" v-if="step === 'preview'"
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer" :disabled="importing"
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
@click="doImport" @click="doImport"
> >
Import {{ importing ? "Importing..." : "Import" }}
</button> </button>
<button <button
v-if="step === 'complete'" v-if="step === 'complete'"
@ -136,13 +144,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { Call } from "@wailsio/runtime";
import { useConnectionStore } from "@/stores/connection.store";
/** Fully qualified Go method name prefix for WraithApp bindings. */
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
type Step = "select" | "preview" | "complete"; type Step = "select" | "preview" | "complete";
const connectionStore = useConnectionStore();
const visible = ref(false); const visible = ref(false);
const step = ref<Step>("select"); const step = ref<Step>("select");
const fileName = ref(""); const fileName = ref("");
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const fileContent = ref("");
const importError = ref<string | null>(null);
const importing = ref(false);
const preview = ref({ const preview = ref({
connections: 0, connections: 0,
@ -155,6 +173,8 @@ function open(): void {
visible.value = true; visible.value = true;
step.value = "select"; step.value = "select";
fileName.value = ""; fileName.value = "";
fileContent.value = "";
importError.value = null;
} }
function close(): void { function close(): void {
@ -183,11 +203,10 @@ function handleDrop(event: DragEvent): void {
async function processFile(file: File): Promise<void> { async function processFile(file: File): Promise<void> {
fileName.value = file.name; fileName.value = file.name;
// TODO: Replace with Wails binding ImporterService.Preview(fileData)
// For now, read and mock-parse the file to show a preview
const text = await file.text(); const text = await file.text();
fileContent.value = text;
// Simple mock parse to count items // Client-side preview parse to count items for display
const lines = text.split("\n"); const lines = text.split("\n");
let groups = 0; let groups = 0;
let connections = 0; let connections = 0;
@ -237,10 +256,21 @@ async function processFile(file: File): Promise<void> {
step.value = "preview"; step.value = "preview";
} }
function doImport(): void { async function doImport(): Promise<void> {
// TODO: Replace with Wails binding ImporterService.Import(fileData) if (!fileContent.value) return;
// For now, just show success importing.value = true;
step.value = "complete"; importError.value = null;
try {
await Call.ByName(`${APP}.ImportMobaConf`, fileContent.value);
// Refresh connection store so sidebar shows imported connections
await connectionStore.loadAll();
step.value = "complete";
} catch (e) {
importError.value = e instanceof Error ? e.message : String(e) || "Import failed";
} finally {
importing.value = false;
}
} }
defineExpose({ open, close, visible }); defineExpose({ open, close, visible });

View File

@ -165,6 +165,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useConnectionStore, type Connection } from "@/stores/connection.store"; 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";
interface ConnectionForm { interface ConnectionForm {
name: string; name: string;
@ -252,7 +256,7 @@ function close(): void {
visible.value = false; visible.value = false;
} }
function save(): void { async function save(): Promise<void> {
if (!isValid.value) return; if (!isValid.value) return;
const tags = tagsInput.value const tags = tagsInput.value
@ -260,32 +264,35 @@ function save(): void {
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => t.length > 0); .filter((t) => t.length > 0);
if (isEditing.value && editingId.value !== null) { try {
// TODO: Replace with Wails binding ConnectionService.UpdateConnection(id, input) if (isEditing.value && editingId.value !== null) {
const conn = connectionStore.connections.find((c) => c.id === editingId.value); await Call.ByName(`${CONN}.UpdateConnection`, editingId.value, {
if (conn) { name: form.value.name,
conn.name = form.value.name; hostname: form.value.hostname,
conn.hostname = form.value.hostname; port: form.value.port,
conn.port = form.value.port; groupId: form.value.groupId,
conn.protocol = form.value.protocol; color: form.value.color,
conn.groupId = form.value.groupId ?? conn.groupId; tags,
conn.tags = 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,
color: form.value.color,
tags,
notes: form.value.notes,
});
} }
} else { // Refresh connections from backend
// TODO: Replace with Wails binding ConnectionService.CreateConnection(input) await connectionStore.loadAll();
const newId = Math.max(...connectionStore.connections.map((c) => c.id), 0) + 1; close();
connectionStore.connections.push({ } catch (err) {
id: newId, console.error("Failed to save connection:", err);
name: form.value.name,
hostname: form.value.hostname,
port: form.value.port,
protocol: form.value.protocol,
groupId: form.value.groupId ?? 1,
tags,
});
} }
close();
} }
defineExpose({ openNew, openEdit, close, visible }); defineExpose({ openNew, openEdit, close, visible });

View File

@ -383,8 +383,10 @@ function handleKeydown(event: KeyboardEvent): void {
} }
} }
onMounted(() => { onMounted(async () => {
document.addEventListener("keydown", handleKeydown); document.addEventListener("keydown", handleKeydown);
// Load connections and groups from the Go backend after vault unlock
await connectionStore.loadAll();
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@ -1,12 +1,13 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { Call } from "@wailsio/runtime";
/** Fully qualified Go method name prefix for WraithApp bindings. */
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
/** /**
* Wraith application store. * Wraith application store.
* Manages unlock state, first-run detection, and vault operations. * Manages unlock state, first-run detection, and vault operations.
*
* Once Wails v3 bindings are generated, the mock calls below will be
* replaced with actual WraithApp.IsFirstRun(), CreateVault(), Unlock(), etc.
*/ */
export const useAppStore = defineStore("app", () => { export const useAppStore = defineStore("app", () => {
const isUnlocked = ref(false); const isUnlocked = ref(false);
@ -17,8 +18,7 @@ export const useAppStore = defineStore("app", () => {
/** Check whether the vault has been created before. */ /** Check whether the vault has been created before. */
async function checkFirstRun(): Promise<void> { async function checkFirstRun(): Promise<void> {
try { try {
// TODO: replace with Wails binding — WraithApp.IsFirstRun() isFirstRun.value = await Call.ByName(`${APP}.IsFirstRun`) as boolean;
isFirstRun.value = true;
} catch { } catch {
isFirstRun.value = true; isFirstRun.value = true;
} finally { } finally {
@ -30,12 +30,11 @@ export const useAppStore = defineStore("app", () => {
async function createVault(password: string): Promise<void> { async function createVault(password: string): Promise<void> {
error.value = null; error.value = null;
try { try {
// TODO: replace with Wails binding — WraithApp.CreateVault(password) await Call.ByName(`${APP}.CreateVault`, password);
void password;
isFirstRun.value = false; isFirstRun.value = false;
isUnlocked.value = true; isUnlocked.value = true;
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Failed to create vault"; error.value = e instanceof Error ? e.message : String(e) || "Failed to create vault";
throw e; throw e;
} }
} }
@ -44,11 +43,10 @@ export const useAppStore = defineStore("app", () => {
async function unlock(password: string): Promise<void> { async function unlock(password: string): Promise<void> {
error.value = null; error.value = null;
try { try {
// TODO: replace with Wails binding — WraithApp.Unlock(password) await Call.ByName(`${APP}.Unlock`, password);
void password;
isUnlocked.value = true; isUnlocked.value = true;
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Invalid master password"; error.value = e instanceof Error ? e.message : String(e) || "Invalid master password";
throw e; throw e;
} }
} }

View File

@ -1,5 +1,9 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { Call } from "@wailsio/runtime";
/** Fully qualified Go method name prefix for ConnectionService bindings. */
const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService";
export interface Connection { export interface Connection {
id: number; id: number;
@ -7,37 +11,35 @@ export interface Connection {
hostname: string; hostname: string;
port: number; port: number;
protocol: "ssh" | "rdp"; protocol: "ssh" | "rdp";
groupId: number; groupId: number | null;
credentialId?: number | null;
color?: string;
tags?: string[]; tags?: string[];
notes?: string;
options?: string;
sortOrder?: number;
lastConnected?: string | null;
createdAt?: string;
updatedAt?: string;
} }
export interface Group { export interface Group {
id: number; id: number;
name: string; name: string;
parentId: number | null; parentId: number | null;
sortOrder?: number;
icon?: string;
children?: Group[];
} }
/** /**
* Connection store. * Connection store.
* Manages connections, groups, and search state. * Manages connections, groups, and search state.
* * Loads data from the Go backend via Wails bindings.
* Uses mock data until Wails bindings are connected.
*/ */
export const useConnectionStore = defineStore("connection", () => { export const useConnectionStore = defineStore("connection", () => {
const connections = ref<Connection[]>([ const connections = ref<Connection[]>([]);
{ id: 1, name: "Asgard", hostname: "192.168.1.4", port: 22, protocol: "ssh", groupId: 1, tags: ["Prod"] }, const groups = ref<Group[]>([]);
{ id: 2, name: "Docker", hostname: "155.254.29.221", port: 22, protocol: "ssh", groupId: 1, tags: ["Prod"] },
{ id: 3, name: "Predator Mac", hostname: "192.168.1.214", port: 22, protocol: "ssh", groupId: 1 },
{ id: 4, name: "CLT-VMHOST01", hostname: "100.64.1.204", port: 3389, protocol: "rdp", groupId: 1 },
{ id: 5, name: "ITFlow", hostname: "192.154.253.106", port: 22, protocol: "ssh", groupId: 2 },
{ id: 6, name: "Mautic", hostname: "192.154.253.112", port: 22, protocol: "ssh", groupId: 2 },
]);
const groups = ref<Group[]>([
{ id: 1, name: "Vantz's Stuff", parentId: null },
{ id: 2, name: "MSPNerd", parentId: null },
]);
const searchQuery = ref(""); const searchQuery = ref("");
/** Filter connections by search query. */ /** Filter connections by search query. */
@ -70,16 +72,31 @@ export const useConnectionStore = defineStore("connection", () => {
return connectionsByGroup(groupId).length > 0; return connectionsByGroup(groupId).length > 0;
} }
/** Load connections from backend (mock for now). */ /** Load connections from the Go backend. */
async function loadConnections(): Promise<void> { async function loadConnections(): Promise<void> {
// TODO: replace with Wails binding — ConnectionService.ListConnections() try {
// connections.value = await ConnectionService.ListConnections(); const conns = await Call.ByName(`${CONN}.ListConnections`) as Connection[];
connections.value = conns || [];
} catch (err) {
console.error("Failed to load connections:", err);
connections.value = [];
}
} }
/** Load groups from backend (mock for now). */ /** Load groups from the Go backend. */
async function loadGroups(): Promise<void> { async function loadGroups(): Promise<void> {
// TODO: replace with Wails binding — ConnectionService.ListGroups() try {
// groups.value = await ConnectionService.ListGroups(); const grps = await Call.ByName(`${CONN}.ListGroups`) as Group[];
groups.value = grps || [];
} catch (err) {
console.error("Failed to load groups:", err);
groups.value = [];
}
}
/** Load both connections and groups from the Go backend. */
async function loadAll(): Promise<void> {
await Promise.all([loadConnections(), loadGroups()]);
} }
return { return {
@ -91,5 +108,6 @@ export const useConnectionStore = defineStore("connection", () => {
groupHasResults, groupHasResults,
loadConnections, loadConnections,
loadGroups, loadGroups,
loadAll,
}; };
}); });

View File

@ -12,6 +12,7 @@ import (
"github.com/vstockwell/wraith/internal/connections" "github.com/vstockwell/wraith/internal/connections"
"github.com/vstockwell/wraith/internal/credentials" "github.com/vstockwell/wraith/internal/credentials"
"github.com/vstockwell/wraith/internal/db" "github.com/vstockwell/wraith/internal/db"
"github.com/vstockwell/wraith/internal/importer"
"github.com/vstockwell/wraith/internal/plugin" "github.com/vstockwell/wraith/internal/plugin"
"github.com/vstockwell/wraith/internal/rdp" "github.com/vstockwell/wraith/internal/rdp"
"github.com/vstockwell/wraith/internal/session" "github.com/vstockwell/wraith/internal/session"
@ -228,3 +229,61 @@ func (a *WraithApp) initCredentials() {
} }
} }
} }
// ImportMobaConf parses a MobaXTerm .mobaconf file and imports its contents
// (groups, connections, host keys) into the database.
func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) {
imp := &importer.MobaConfImporter{}
result, err := imp.Parse([]byte(fileContent))
if err != nil {
return nil, fmt.Errorf("parse mobaconf: %w", err)
}
// Create groups and track name -> ID mapping
groupMap := make(map[string]int64)
for _, g := range result.Groups {
created, err := a.Connections.CreateGroup(g.Name, nil)
if err != nil {
slog.Warn("failed to create group during import", "name", g.Name, "error", err)
continue
}
groupMap[g.Name] = created.ID
}
// Create connections
for _, c := range result.Connections {
var groupID *int64
if id, ok := groupMap[c.GroupName]; ok {
groupID = &id
}
_, err := a.Connections.CreateConnection(connections.CreateConnectionInput{
Name: c.Name,
Hostname: c.Hostname,
Port: c.Port,
Protocol: c.Protocol,
GroupID: groupID,
Notes: c.Notes,
})
if err != nil {
slog.Warn("failed to create connection during import", "name", c.Name, "error", err)
}
}
// Store host keys
for _, hk := range result.HostKeys {
_, err := a.db.Exec(
`INSERT OR IGNORE INTO host_keys (hostname, port, key_type, fingerprint) VALUES (?, ?, ?, ?)`,
hk.Hostname, hk.Port, hk.KeyType, hk.Fingerprint,
)
if err != nil {
slog.Warn("failed to store host key during import", "hostname", hk.Hostname, "error", err)
}
}
slog.Info("mobaconf import complete",
"groups", len(result.Groups),
"connections", len(result.Connections),
"hostKeys", len(result.HostKeys),
)
return result, nil
}