Files
Netcatty/application/state/useVaultState.ts
T
bincxz 5eb2a196d5 Adds ability to clear all local vault data
Introduces a function to reset vault-related state and storage data, and wires it into the settings and cloud sync workflow.
Facilitates easier local data management and resolves potential sync or migration issues by allowing users to clear stored vault data via the UI.
2026-01-01 20:29:24 +08:00

498 lines
14 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import {
ConnectionLog,
Host,
Identity,
KeyCategory,
KnownHost,
ShellHistoryEntry,
Snippet,
SSHKey,
} from "../../domain/models";
import {
INITIAL_HOSTS,
INITIAL_SNIPPETS,
} from "../../infrastructure/config/defaultData";
import {
STORAGE_KEY_CONNECTION_LOGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
STORAGE_KEY_IDENTITIES,
STORAGE_KEY_KEYS,
STORAGE_KEY_KNOWN_HOSTS,
STORAGE_KEY_LEGACY_KEYS,
STORAGE_KEY_SHELL_HISTORY,
STORAGE_KEY_SNIPPET_PACKAGES,
STORAGE_KEY_SNIPPETS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
type ExportableVaultData = {
hosts: Host[];
keys: SSHKey[];
identities?: Identity[];
snippets: Snippet[];
customGroups: string[];
knownHosts?: KnownHost[];
};
type LegacyKeyRecord = Record<string, unknown> & { id?: string; source?: string };
// Migration helper for old SSHKey format to new format
const migrateKey = (key: Partial<SSHKey>): SSHKey => {
const id = key.id ?? crypto.randomUUID();
const label = key.label ?? `Key ${id.slice(0, 8)}`;
const source =
key.source === "generated" || key.source === "imported"
? key.source
: key.privateKey
? "imported"
: "generated";
return {
id,
label,
type: key.type || "ED25519",
privateKey: key.privateKey || "",
publicKey: key.publicKey,
certificate: key.certificate,
passphrase: key.passphrase,
savePassphrase: key.savePassphrase,
source,
category:
key.category ||
((key.certificate ? "certificate" : "key") as KeyCategory),
created: key.created || Date.now(),
};
};
const isLegacyUnsupportedKey = (key: LegacyKeyRecord): boolean => {
const source = key.source;
if (source === "biometric" || source === "fido2" || source === "passkey") return true;
// Legacy experimental WebAuthn fields
if ("credentialId" in key || "rpId" in key || "userVerification" in key) return true;
return false;
};
const safeParse = <T,>(value: string | null): T | null => {
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
};
export const useVaultState = () => {
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
const [identities, setIdentities] = useState<Identity[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [customGroups, setCustomGroups] = useState<string[]>([]);
const [snippetPackages, setSnippetPackages] = useState<string[]>([]);
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
setHosts(cleaned);
localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned);
}, []);
const updateKeys = useCallback((data: SSHKey[]) => {
setKeys(data);
localStorageAdapter.write(STORAGE_KEY_KEYS, data);
}, []);
const updateIdentities = useCallback((data: Identity[]) => {
setIdentities(data);
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, data);
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
setSnippets(data);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data);
}, []);
const updateSnippetPackages = useCallback((data: string[]) => {
setSnippetPackages(data);
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
}, []);
const updateCustomGroups = useCallback((data: string[]) => {
setCustomGroups(data);
localStorageAdapter.write(STORAGE_KEY_GROUPS, data);
}, []);
const updateKnownHosts = useCallback((data: KnownHost[]) => {
setKnownHosts(data);
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
}, []);
const clearVaultData = useCallback(() => {
updateHosts([]);
updateKeys([]);
updateIdentities([]);
updateSnippets([]);
updateSnippetPackages([]);
updateCustomGroups([]);
updateKnownHosts([]);
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
}, [
updateHosts,
updateKeys,
updateIdentities,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
]);
const addShellHistoryEntry = useCallback(
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
const newEntry: ShellHistoryEntry = {
...entry,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
setShellHistory((prev) => {
// Keep only the last 1000 entries
const updated = [newEntry, ...prev].slice(0, 1000);
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
return updated;
});
},
[],
);
const clearShellHistory = useCallback(() => {
setShellHistory([]);
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, []);
}, []);
// Connection logs management
const addConnectionLog = useCallback(
(log: Omit<ConnectionLog, "id">) => {
const newLog: ConnectionLog = {
...log,
id: crypto.randomUUID(),
};
setConnectionLogs((prev) => {
// Keep only the last 500 non-saved entries plus all saved entries
const savedLogs = prev.filter((l) => l.saved);
const unsavedLogs = prev.filter((l) => !l.saved);
const updated = [newLog, ...unsavedLogs].slice(0, 500);
const final = [...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, final);
return final;
});
return newLog.id;
},
[]
);
const updateConnectionLog = useCallback(
(id: string, updates: Partial<ConnectionLog>) => {
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, ...updates } : log
);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
return updated;
});
},
[]
);
const toggleConnectionLogSaved = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
return updated;
});
}, []);
const deleteConnectionLog = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = prev.filter((log) => log.id !== id);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
return updated;
});
}, []);
const clearUnsavedConnectionLogs = useCallback(() => {
setConnectionLogs((prev) => {
const saved = prev.filter((log) => log.saved);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, saved);
return saved;
});
}, []);
// Convert a known host to a managed host
const convertKnownHostToHost = useCallback((knownHost: KnownHost): Host => {
const newHost: Host = {
id: `host-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
label: knownHost.hostname,
hostname: knownHost.hostname,
port: knownHost.port,
username: "", // Will be set when connecting
os: "linux",
group: "",
tags: [],
protocol: "ssh",
};
// Update the known host to mark it as converted using functional update
setKnownHosts((prevKnownHosts) => {
const updated = prevKnownHosts.map((kh) =>
kh.id === knownHost.id ? { ...kh, convertedToHostId: newHost.id } : kh,
);
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, updated);
return updated;
});
// Add to hosts using functional update
setHosts((prevHosts) => {
const updated = [...prevHosts, sanitizeHost(newHost)];
localStorageAdapter.write(STORAGE_KEY_HOSTS, updated);
return updated;
});
return newHost;
}, []);
useEffect(() => {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedHosts) {
const sanitized = savedHosts.map(sanitizeHost);
setHosts(sanitized);
localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized);
} else {
updateHosts(INITIAL_HOSTS);
}
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
setKeys(migratedKeys);
// Persist migrated keys
localStorageAdapter.write(STORAGE_KEY_KEYS, migratedKeys);
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
if (savedIdentities) setIdentities(savedIdentities);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
}, [updateHosts, updateSnippets]);
useEffect(() => {
if (typeof window === "undefined") return;
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage) return;
const key = event.key;
if (!key) return;
if (key === STORAGE_KEY_HOSTS) {
const next = safeParse<Host[]>(event.newValue) ?? [];
setHosts(next.map(sanitizeHost));
return;
}
if (key === STORAGE_KEY_KEYS) {
const raw = safeParse<unknown[]>(event.newValue) ?? [];
const migratedKeys: SSHKey[] = [];
for (const entry of raw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record || isLegacyUnsupportedKey(record)) continue;
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
setKeys(migratedKeys);
return;
}
if (key === STORAGE_KEY_IDENTITIES) {
const next = safeParse<Identity[]>(event.newValue) ?? [];
setIdentities(next);
return;
}
if (key === STORAGE_KEY_SNIPPETS) {
const next = safeParse<Snippet[]>(event.newValue) ?? [];
setSnippets(next);
return;
}
if (key === STORAGE_KEY_GROUPS) {
const next = safeParse<string[]>(event.newValue) ?? [];
setCustomGroups(next);
return;
}
if (key === STORAGE_KEY_SNIPPET_PACKAGES) {
const next = safeParse<string[]>(event.newValue) ?? [];
setSnippetPackages(next);
return;
}
if (key === STORAGE_KEY_KNOWN_HOSTS) {
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
setKnownHosts(next);
return;
}
if (key === STORAGE_KEY_SHELL_HISTORY) {
const next = safeParse<ShellHistoryEntry[]>(event.newValue) ?? [];
setShellHistory(next);
return;
}
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
setConnectionLogs(next);
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const updateHostDistro = useCallback((hostId: string, distro: string) => {
const normalized = normalizeDistroId(distro);
setHosts((prev) => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, distro: normalized } : h,
);
localStorageAdapter.write(STORAGE_KEY_HOSTS, next);
return next;
});
}, []);
const exportData = useCallback(
(): ExportableVaultData => ({
hosts,
keys,
identities,
snippets,
customGroups,
knownHosts,
}),
[hosts, keys, identities, snippets, customGroups, knownHosts],
);
const importData = useCallback(
(payload: Partial<ExportableVaultData>) => {
if (payload.hosts) updateHosts(payload.hosts);
if (payload.keys) updateKeys(payload.keys);
if (payload.identities) updateIdentities(payload.identities);
if (payload.snippets) updateSnippets(payload.snippets);
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
},
[
updateHosts,
updateKeys,
updateIdentities,
updateSnippets,
updateCustomGroups,
updateKnownHosts,
],
);
const importDataFromString = useCallback(
(jsonString: string) => {
const data = JSON.parse(jsonString);
importData(data);
},
[importData],
);
return {
hosts,
keys,
identities,
snippets,
customGroups,
snippetPackages,
knownHosts,
shellHistory,
connectionLogs,
updateHosts,
updateKeys,
updateIdentities,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
addShellHistoryEntry,
clearShellHistory,
addConnectionLog,
updateConnectionLog,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
updateHostDistro,
convertKnownHostToHost,
exportData,
importDataFromString,
clearVaultData,
};
};