Files
secrets/apps/desktop/dist/main.js
agent 57c3efb70e feat(auth): 服务端托管 Google OAuth;修复未解锁 vault 时 bootstrap
- API:桌面登录 session、Google 托管回调与轮询
- Desktop:轮询登录;bootstrap 在 vault 未解锁时不返回 shell,避免跳过主密码
- 文档与 deploy/.env.example 对齐 GOOGLE_OAUTH_* 与 SECRETS_PUBLIC_BASE_URL
2026-04-14 22:05:11 +08:00

1021 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const invoke = window.__TAURI_INTERNALS__?.invoke;
const loginView = document.getElementById("login-view");
const appShell = document.getElementById("app-shell");
const loginButton = document.getElementById("login-button");
const loginError = document.getElementById("login-error");
const vaultModal = document.getElementById("vault-modal");
const vaultModalTitle = document.getElementById("vault-modal-title");
const vaultModalCopy = document.getElementById("vault-modal-copy");
const vaultPasswordInput = document.getElementById("vault-password-input");
const vaultModalError = document.getElementById("vault-modal-error");
const vaultModalSave = document.getElementById("vault-modal-save");
const logoutButton = document.getElementById("logout-button");
const userTrigger = document.getElementById("user-trigger");
const userMenu = document.getElementById("user-menu");
const manageDevicesButton = document.getElementById("manage-devices");
const openMcpModalButton = document.getElementById("open-mcp-modal");
const deviceModal = document.getElementById("device-modal");
const deviceList = document.getElementById("device-list");
const closeDeviceModal = document.getElementById("close-device-modal");
const mcpModal = document.getElementById("mcp-modal");
const closeMcpModal = document.getElementById("close-mcp-modal");
const mcpIntegrationList = document.getElementById("mcp-integration-list");
const mcpConfig = document.getElementById("mcp-config");
const copyMcpConfigButton = document.getElementById("copy-mcp-config");
const copyMcpConfigButtonLabel = copyMcpConfigButton.querySelector(".button-label");
const userName = document.getElementById("user-name");
const userEmail = document.getElementById("user-email");
const folderList = document.getElementById("folder-list");
const entryList = document.getElementById("entry-list");
const detailFolderLabel = document.getElementById("detail-folder-label");
const entryTitle = document.getElementById("entry-title");
const metadataList = document.getElementById("metadata-list");
const secretList = document.getElementById("secret-list");
const searchInput = document.getElementById("search-input");
const typeFilter = document.getElementById("type-filter");
const detailBadge = document.getElementById("detail-badge");
const editEntryButton = document.getElementById("edit-entry-button");
const deleteEntryButton = document.getElementById("delete-entry-button");
const restoreEntryButton = document.getElementById("restore-entry-button");
const saveEntryButton = document.getElementById("save-entry-button");
const cancelEditButton = document.getElementById("cancel-edit-button");
const addSecretButton = document.getElementById("add-secret-button");
const newEntryButton = document.getElementById("new-entry-button");
const nameSection = document.getElementById("name-section");
const nameView = document.getElementById("name-view");
const nameInput = document.getElementById("name-input");
const metadataEditor = document.getElementById("metadata-editor");
const addMetadataButton = document.getElementById("add-metadata-button");
const entryModal = document.getElementById("entry-modal");
const closeEntryModal = document.getElementById("close-entry-modal");
const entryModalCancel = document.getElementById("entry-modal-cancel");
const entryModalSave = document.getElementById("entry-modal-save");
const entryModalFolder = document.getElementById("entry-modal-folder");
const entryModalTitle = document.getElementById("entry-modal-title");
const entryModalType = document.getElementById("entry-modal-type");
const secretModal = document.getElementById("secret-modal");
const closeSecretModal = document.getElementById("close-secret-modal");
const secretModalCancel = document.getElementById("secret-modal-cancel");
const secretModalSave = document.getElementById("secret-modal-save");
const secretModalTitle = document.getElementById("secret-modal-title");
const secretNameInput = document.getElementById("secret-name-input");
const secretTypeInput = document.getElementById("secret-type-input");
const secretValueInput = document.getElementById("secret-value-input");
const historyModal = document.getElementById("history-modal");
const closeHistoryModal = document.getElementById("close-history-modal");
const historyModalCopy = document.getElementById("history-modal-copy");
const historyList = document.getElementById("history-list");
const appState = {
shell: null,
activeFolder: "所有项目",
activeType: "",
query: "",
mcpJson: "",
editing: false,
draftMetadata: [],
revealedSecrets: {},
secretModalMode: "create",
editingSecretId: null,
historySecretId: null,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function displaySecretName(name) {
const normalized = String(name ?? "")
.trim()
.toLowerCase()
.replaceAll("_", " ");
if (normalized === "api token" || normalized === "token") return "访问令牌";
return String(name ?? "");
}
function integrationGlyph(appName) {
if (appName === "Cursor") return "⌘";
if (appName === "Claude Code") return ">_";
return "⌁";
}
function selectedEntry() {
return appState.shell?.selected_entry || null;
}
function resetSecretCache() {
appState.revealedSecrets = {};
}
function setSelectedDetail(detail) {
if (!appState.shell) return;
appState.shell.selected_entry_id = detail?.id || null;
appState.shell.selected_entry = detail;
appState.editing = false;
appState.draftMetadata = detail?.metadata ? detail.metadata.map((item) => ({ ...item })) : [];
resetSecretCache();
}
function showLogin() {
loginView.classList.remove("hidden");
appShell.classList.add("hidden");
}
function showShell() {
loginView.classList.add("hidden");
appShell.classList.remove("hidden");
}
function setLoginError(message) {
loginError.textContent = message || "";
loginError.classList.toggle("hidden", !message);
}
function setVaultModalError(message) {
vaultModalError.textContent = message || "";
vaultModalError.classList.toggle("hidden", !message);
}
function openVaultModal({ setup = false } = {}) {
vaultModalTitle.textContent = setup ? "设置本地 Vault 主密码" : "解锁本地 Vault";
vaultModalCopy.textContent = setup
? "首次使用,请先设置本地 vault 主密码。"
: "请输入本地 vault 主密码以解锁。";
vaultPasswordInput.value = "";
setVaultModalError("");
vaultModal.classList.remove("hidden");
setTimeout(() => vaultPasswordInput.focus(), 0);
}
function closeVaultModal() {
vaultModal.classList.add("hidden");
vaultPasswordInput.value = "";
setVaultModalError("");
}
function setAvatar() {
const avatar = document.querySelector(".avatar");
const initial = appState.shell?.user?.name?.trim()?.[0] || "S";
avatar.textContent = initial.toUpperCase();
}
function renderFolders() {
if (!appState.shell) return;
userName.textContent = appState.shell.user.name;
userEmail.textContent = appState.shell.user.email;
setAvatar();
const activeFolder = appState.activeFolder;
const folders = [
{
label: "所有项目",
count: appState.shell.folders
.filter((folder) => folder.kind === "folder")
.reduce((sum, folder) => sum + folder.count, 0),
},
...appState.shell.folders,
];
folderList.innerHTML = folders
.map(
(folder) => `
<button class="folder-item ${folder.label === activeFolder ? "active" : ""}" data-folder="${escapeHtml(folder.label)}">
<span>${escapeHtml(folder.label)}</span>
<span class="folder-count">${folder.count}</span>
</button>
`
)
.join("");
}
function renderTypeFilter() {
if (!appState.shell) return;
typeFilter.innerHTML = [
`<option value="">全部类型</option>`,
...appState.shell.entry_types.map(
(entryType) =>
`<option value="${escapeHtml(entryType)}" ${entryType === appState.activeType ? "selected" : ""}>${escapeHtml(entryType)}</option>`
),
].join("");
}
function renderEntries() {
if (!appState.shell) return;
const entries = appState.shell.entries;
const selectedId = appState.shell.selected_entry_id;
if (!entries.length) {
entryList.innerHTML = `
<div class="empty-card">
<div class="empty-title">没有匹配的条目</div>
<div class="empty-copy">调整搜索词、类型筛选或文件夹后再试。</div>
</div>
`;
return;
}
entryList.innerHTML = entries
.map(
(entry) => `
<button class="entry-item ${entry.id === selectedId ? "active" : ""}" data-entry-id="${escapeHtml(entry.id)}">
<div class="entry-title">${escapeHtml(entry.title)}</div>
<div class="entry-subtitle">${escapeHtml(entry.subtitle)}</div>
</button>
`
)
.join("");
}
function renderMetadataEditor() {
metadataEditor.innerHTML = appState.draftMetadata
.map(
(field, index) => `
<div class="metadata-row">
<input class="detail-input" data-metadata-key="${index}" value="${escapeHtml(field.label)}" placeholder="元数据名称" />
<input class="detail-input" data-metadata-value="${index}" value="${escapeHtml(field.value)}" placeholder="元数据内容" />
<button class="secondary-button small danger" data-remove-metadata="${index}">删除</button>
</div>
`
)
.join("");
}
function syncEditMode() {
const editing = appState.editing;
const hasSelection = Boolean(selectedEntry());
const deleted = Boolean(selectedEntry()?.deleted);
editEntryButton.classList.toggle("hidden", editing || !hasSelection || deleted);
saveEntryButton.classList.toggle("hidden", !editing);
cancelEditButton.classList.toggle("hidden", !editing);
nameSection.classList.toggle("hidden", !editing);
nameView.classList.add("hidden");
nameInput.classList.toggle("hidden", !editing);
metadataList.classList.toggle("hidden", editing);
metadataEditor.classList.toggle("hidden", !editing);
addMetadataButton.classList.toggle("hidden", !editing);
addSecretButton.classList.toggle("hidden", editing || !hasSelection || deleted);
}
function renderSecretCard(secret) {
const revealed = appState.revealedSecrets[secret.id];
const isVisible = Boolean(revealed?.visible);
const value = isVisible ? revealed.value : secret.masked_value;
return `
<div class="secret-card">
<div class="secret-type">${escapeHtml(displaySecretName(secret.name))}</div>
<div class="secret-row">
<div class="secret-shell">
<span class="secret-leading-icon" aria-hidden="true">🔒</span>
<div class="secret-value">${escapeHtml(value)}</div>
<button class="secret-inline-action" type="button" data-secret-action="toggle" data-secret-id="${escapeHtml(secret.id)}" aria-label="${isVisible ? "隐藏密钥" : "显示密钥"}">
${isVisible ? "◑" : "◔"}
</button>
</div>
<div class="secret-actions">
<button class="secondary-button small secret-copy-button" type="button" data-secret-action="copy" data-secret-id="${escapeHtml(secret.id)}">
<span class="button-icon" aria-hidden="true">⧉</span>
<span class="button-label">复制</span>
</button>
<button class="secondary-button small" type="button" data-secret-action="edit" data-secret-id="${escapeHtml(secret.id)}">
<span class="button-label">编辑</span>
</button>
<button class="secondary-button small" type="button" data-secret-action="history" data-secret-id="${escapeHtml(secret.id)}">
<span class="button-label">历史</span>
</button>
<button class="secondary-button small danger" type="button" data-secret-action="delete" data-secret-id="${escapeHtml(secret.id)}">
<span class="button-label">删除</span>
</button>
</div>
</div>
</div>
`;
}
function renderDetail() {
const detail = selectedEntry();
if (!detail) {
detailFolderLabel.textContent = "-";
entryTitle.textContent = "未选择条目";
nameView.textContent = "-";
nameInput.value = "";
metadataList.innerHTML = `<div class="empty-card"><div class="empty-copy">从左侧列表选择一个条目后,这里会显示结构化元数据。</div></div>`;
metadataEditor.innerHTML = "";
secretList.innerHTML = `<div class="empty-card"><div class="empty-copy">选中条目后会在这里显示受保护的密钥字段。</div></div>`;
detailBadge.classList.add("hidden");
deleteEntryButton.classList.add("hidden");
restoreEntryButton.classList.add("hidden");
syncEditMode();
return;
}
detailFolderLabel.textContent = detail.folder;
entryTitle.textContent = detail.title;
nameView.textContent = detail.title;
nameInput.value = detail.title;
detailBadge.classList.toggle("hidden", !detail.deleted);
deleteEntryButton.classList.toggle("hidden", detail.deleted);
restoreEntryButton.classList.toggle("hidden", !detail.deleted);
metadataList.innerHTML = detail.metadata.length
? detail.metadata
.map(
(field) => `
<div class="detail-field">
<div class="detail-label">${escapeHtml(field.label)}</div>
<div class="detail-value">${escapeHtml(field.value)}</div>
</div>
`
)
.join("")
: `<div class="empty-card"><div class="empty-copy">当前条目还没有元数据。</div></div>`;
renderMetadataEditor();
secretList.innerHTML = detail.secrets.length
? detail.secrets.map(renderSecretCard).join("")
: `<div class="empty-card"><div class="empty-copy">当前条目还没有密钥字段。</div></div>`;
syncEditMode();
}
function renderShell(shell) {
appState.shell = shell;
appState.activeFolder = "所有项目";
appState.activeType = "";
appState.query = "";
appState.editing = false;
appState.draftMetadata = shell.selected_entry?.metadata
? shell.selected_entry.metadata.map((item) => ({ ...item }))
: [];
resetSecretCache();
searchInput.value = "";
renderFolders();
renderTypeFilter();
renderEntries();
renderDetail();
}
async function ensureUnlockedShell(data) {
if (!data?.logged_in) return data;
if (data?.shell) return data;
const vault = data?.vault;
if (!vault) return data;
return new Promise((resolve, reject) => {
const setup = !vault.has_master_password;
openVaultModal({ setup });
const submit = async () => {
const password = vaultPasswordInput.value.trim();
if (!password) {
setVaultModalError(setup ? "请先设置本地 vault 主密码" : "请输入本地 vault 主密码");
return;
}
vaultModalSave.disabled = true;
try {
if (setup) {
await invoke("setup_master_password", { password });
} else {
await invoke("unlock_vault", { password });
}
cleanup();
closeVaultModal();
const bootstrapped = await invoke("app_bootstrap");
resolve(bootstrapped);
} catch (error) {
setVaultModalError(String(error));
} finally {
vaultModalSave.disabled = false;
}
};
const onClick = () => {
void submit();
};
const onKeydown = (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
void submit();
};
const cleanup = () => {
vaultModalSave.removeEventListener("click", onClick);
vaultPasswordInput.removeEventListener("keydown", onKeydown);
};
vaultModalSave.addEventListener("click", onClick);
vaultPasswordInput.addEventListener("keydown", onKeydown);
});
}
function renderMcpDialog(data) {
appState.mcpJson = data.mcp_json;
mcpIntegrationList.innerHTML = data.integrations
.map(
(integration) => `
<div class="integration-row-card">
<div class="integration-app">
<div class="integration-icon" aria-hidden="true">${integrationGlyph(integration.app_name)}</div>
<div>
<div class="entry-title">${escapeHtml(integration.app_name)}</div>
</div>
</div>
<button class="integration-toggle ${integration.configured ? "is-on" : ""}" data-apply-target="${
integration.app_name === "Cursor" ? "cursor" : "claude-code"
}" aria-label="写入 ${integration.app_name}">
<span class="integration-toggle-knob"></span>
</button>
</div>
`
)
.join("");
mcpConfig.textContent = data.mcp_json;
}
function closeEntryModalPanel() {
entryModal.classList.add("hidden");
entryModalFolder.value = "";
entryModalTitle.value = "";
entryModalType.value = "";
}
function openEntryModalPanel() {
const detail = selectedEntry();
entryModalFolder.value = detail?.folder || "Refining";
entryModalType.value = detail?.entry_type || "service";
entryModalTitle.value = "";
entryModal.classList.remove("hidden");
}
function closeSecretModalPanel() {
secretModal.classList.add("hidden");
appState.secretModalMode = "create";
appState.editingSecretId = null;
secretNameInput.value = "";
secretTypeInput.value = "text";
secretValueInput.value = "";
}
function openSecretModalPanel(mode, options = {}) {
appState.secretModalMode = mode;
appState.editingSecretId = options.secretId || null;
secretModalTitle.textContent = mode === "edit" ? "编辑密钥" : "新增密钥";
secretNameInput.value = options.name || "";
secretTypeInput.value = options.secretType || "text";
secretValueInput.value = options.value || "";
secretModal.classList.remove("hidden");
}
function closeHistoryModalPanel() {
historyModal.classList.add("hidden");
appState.historySecretId = null;
historyList.innerHTML = "";
}
function setTransientLabel(labelNode, nextText, originalText) {
if (!labelNode) return;
labelNode.textContent = nextText;
setTimeout(() => {
labelNode.textContent = originalText;
}, 1200);
}
async function bootstrap() {
if (!invoke) {
showLogin();
return;
}
try {
let data = await invoke("app_bootstrap");
if (!data.logged_in) {
showLogin();
return;
}
data = await ensureUnlockedShell(data);
renderShell(data.shell);
showShell();
} catch (error) {
setLoginError(String(error));
showLogin();
}
}
async function doDemoLogin() {
if (!invoke) return;
setLoginError("");
loginButton.disabled = true;
loginButton.textContent = "正在打开浏览器...";
try {
let data = await invoke("continue_demo_login");
data = await ensureUnlockedShell(data);
renderShell(data.shell);
showShell();
} catch (error) {
setLoginError(String(error));
} finally {
loginButton.textContent = "前往浏览器登录";
loginButton.disabled = false;
}
}
async function doLogout() {
if (!invoke) return;
await invoke("logout");
userMenu.classList.add("hidden");
appState.shell = null;
showLogin();
}
async function loadEntryDetail(entryId) {
const detail = await invoke("entry_detail", { entryId });
setSelectedDetail(detail);
renderEntries();
renderDetail();
}
async function refreshEntries() {
if (!appState.shell) return;
const deletedOnly = appState.activeFolder === "最近删除";
const folder =
appState.activeFolder === "所有项目" || deletedOnly
? null
: appState.activeFolder;
const query = appState.query.trim() || null;
const entries = await invoke("list_entries", {
query: {
folder,
entryType: appState.activeType || null,
query,
deletedOnly,
},
});
appState.shell.entries = entries;
renderEntries();
const nextSelected = entries.find((entry) => entry.id === appState.shell.selected_entry_id) || entries[0];
if (!nextSelected) {
setSelectedDetail(null);
renderDetail();
return;
}
if (!selectedEntry() || nextSelected.id !== appState.shell.selected_entry_id) {
await loadEntryDetail(nextSelected.id);
return;
}
renderDetail();
}
async function refreshShell() {
const data = await invoke("app_bootstrap");
if (!data.logged_in || !data.shell) return;
const previousFolder = appState.activeFolder;
const previousType = appState.activeType;
const previousQuery = appState.query;
const previousSelectedId = appState.shell?.selected_entry_id;
renderShell(data.shell);
appState.activeFolder = previousFolder;
appState.activeType = previousType;
appState.query = previousQuery;
renderFolders();
renderTypeFilter();
await refreshEntries();
if (previousSelectedId && appState.shell.entries.some((entry) => entry.id === previousSelectedId)) {
await loadEntryDetail(previousSelectedId);
}
}
function applyUpdatedDetail(detail) {
if (!appState.shell) return;
const current = appState.shell.entries.find((entry) => entry.id === detail.id);
if (current) {
current.title = detail.title;
current.subtitle = detail.entry_type;
current.folder = detail.folder;
}
setSelectedDetail(detail);
renderEntries();
renderDetail();
}
async function openDevices() {
if (!invoke) return;
const devices = await invoke("device_list");
deviceList.innerHTML = devices.length
? devices
.map(
(device) => `
<div class="device-card">
<div class="entry-title">${escapeHtml(device.name)}</div>
<div class="entry-subtitle">${escapeHtml(device.platform)} · ${escapeHtml(device.client_version)}</div>
<div class="detail-value">${escapeHtml(device.last_seen)}</div>
${
device.ip
? `<div class="entry-subtitle">IP · ${escapeHtml(device.ip)}</div>`
: ""
}
</div>
`
)
.join("")
: `<div class="empty-card"><div class="empty-copy">当前没有在线设备。</div></div>`;
deviceModal.classList.remove("hidden");
userMenu.classList.add("hidden");
}
async function openMcpDialog() {
if (!invoke) return;
const data = await invoke("mcp_dialog_data");
renderMcpDialog(data);
mcpModal.classList.remove("hidden");
}
async function ensureSecretValue(secretId, { visible = false } = {}) {
const existing = appState.revealedSecrets[secretId];
if (existing) {
if (visible) existing.visible = true;
return existing;
}
const revealed = await invoke("reveal_secret_value", { secretId });
appState.revealedSecrets[secretId] = { ...revealed, visible };
return appState.revealedSecrets[secretId];
}
async function toggleSecretVisibility(secretId) {
const existing = appState.revealedSecrets[secretId];
if (existing?.visible) {
existing.visible = false;
} else {
await ensureSecretValue(secretId, { visible: true });
}
renderDetail();
}
async function copySecretValue(secretId, button) {
const revealed = await ensureSecretValue(secretId);
await navigator.clipboard.writeText(revealed.value);
setTransientLabel(button?.querySelector(".button-label"), "已复制", "复制");
}
async function beginEditSecret(secretId) {
const detail = selectedEntry();
const secret = detail?.secrets.find((item) => item.id === secretId);
if (!secret) return;
const revealed = await ensureSecretValue(secretId);
openSecretModalPanel("edit", {
secretId,
name: secret.name,
secretType: secret.secret_type,
value: revealed.value,
});
}
async function openSecretHistory(secretId) {
const detail = selectedEntry();
const secret = detail?.secrets.find((item) => item.id === secretId);
if (!secret) return;
const history = await invoke("secret_history", { secretId });
appState.historySecretId = secretId;
historyModalCopy.textContent = `查看 ${secret.name} 的历史版本并选择回滚目标。`;
historyList.innerHTML = history.length
? history
.map(
(item) => `
<div class="history-item">
<div class="history-topline">
<div class="entry-title">v${item.version} · ${escapeHtml(item.action)}</div>
<div class="entry-subtitle">${escapeHtml(item.created_at)}</div>
</div>
<div class="history-meta">
<span>名称:${escapeHtml(item.name)}</span>
<span>类型:${escapeHtml(item.secret_type)}</span>
<span>history_id${item.history_id}</span>
</div>
<div class="history-value">${escapeHtml(item.value)}</div>
<div class="history-actions">
<button class="secondary-button small" data-history-version="${item.version}" data-history-id="${item.history_id}">
回滚到此版本
</button>
</div>
</div>
`
)
.join("")
: `<div class="empty-card"><div class="empty-copy">当前密钥还没有历史记录。</div></div>`;
historyModal.classList.remove("hidden");
}
async function saveSecretModalState() {
const detail = selectedEntry();
if (!detail) return;
const payload = {
name: secretNameInput.value.trim(),
secretType: secretTypeInput.value || "text",
value: secretValueInput.value,
};
if (!payload.name || !payload.value) {
window.alert("请填写完整的密钥名称和内容。");
return;
}
if (appState.secretModalMode === "edit" && appState.editingSecretId) {
const updated = await invoke("update_secret", {
secret: {
id: appState.editingSecretId,
name: payload.name,
secretType: payload.secretType,
value: payload.value,
},
});
closeSecretModalPanel();
applyUpdatedDetail(updated);
return;
}
const created = await invoke("create_secret", {
entryId: detail.id,
secret: payload,
});
closeSecretModalPanel();
applyUpdatedDetail(created);
}
async function createNewEntry() {
const folder = entryModalFolder.value.trim();
const title = entryModalTitle.value.trim();
const entryType = entryModalType.value.trim() || "service";
if (!folder || !title) {
window.alert("请填写项目和名称。");
return;
}
const detail = await invoke("create_entry", {
entry: {
folder,
title,
entryType,
metadata: [],
secrets: [],
},
});
closeEntryModalPanel();
appState.activeFolder = folder;
await refreshShell();
await loadEntryDetail(detail.id);
}
folderList.addEventListener("click", async (event) => {
const button = event.target.closest("[data-folder]");
if (!button || !appState.shell) return;
appState.activeFolder = button.dataset.folder;
appState.editing = false;
renderFolders();
await refreshEntries();
});
entryList.addEventListener("click", async (event) => {
const button = event.target.closest("[data-entry-id]");
if (!button) return;
const { entryId } = button.dataset;
if (!entryId || entryId === appState.shell?.selected_entry_id) return;
await loadEntryDetail(entryId);
});
searchInput.addEventListener("input", async () => {
appState.query = searchInput.value;
await refreshEntries();
});
typeFilter.addEventListener("change", async () => {
appState.activeType = typeFilter.value;
await refreshEntries();
});
editEntryButton.addEventListener("click", () => {
const detail = selectedEntry();
if (!detail) return;
appState.editing = true;
appState.draftMetadata = detail.metadata.map((item) => ({ ...item }));
renderDetail();
});
cancelEditButton.addEventListener("click", () => {
appState.editing = false;
appState.draftMetadata = selectedEntry()?.metadata.map((item) => ({ ...item })) || [];
renderDetail();
});
metadataEditor.addEventListener("input", (event) => {
const target = event.target;
const keyIndex = target.dataset.metadataKey;
const valueIndex = target.dataset.metadataValue;
if (keyIndex !== undefined) {
appState.draftMetadata[Number(keyIndex)].label = target.value;
}
if (valueIndex !== undefined) {
appState.draftMetadata[Number(valueIndex)].value = target.value;
}
});
metadataEditor.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-metadata]");
if (!button) return;
const index = Number(button.dataset.removeMetadata);
appState.draftMetadata.splice(index, 1);
renderMetadataEditor();
});
addMetadataButton.addEventListener("click", () => {
appState.draftMetadata.push({ label: "", value: "" });
renderMetadataEditor();
});
saveEntryButton.addEventListener("click", async () => {
const detail = selectedEntry();
if (!detail) return;
try {
const updated = await invoke("update_entry_detail", {
entry: {
...detail,
title: nameInput.value.trim(),
metadata: appState.draftMetadata.filter((item) => item.label.trim()),
},
});
applyUpdatedDetail(updated);
} catch (error) {
window.alert(String(error));
}
});
deleteEntryButton.addEventListener("click", async () => {
if (!appState.shell?.selected_entry_id) return;
if (!window.confirm("确定要将当前条目移到最近删除吗?")) return;
try {
await invoke("delete_entry", { entryId: appState.shell.selected_entry_id });
appState.editing = false;
await refreshShell();
await refreshEntries();
} catch (error) {
window.alert(String(error));
}
});
restoreEntryButton.addEventListener("click", async () => {
if (!appState.shell?.selected_entry_id) return;
try {
await invoke("restore_deleted_entry", { entryId: appState.shell.selected_entry_id });
appState.activeFolder = "所有项目";
appState.editing = false;
await refreshShell();
await refreshEntries();
} catch (error) {
window.alert(String(error));
}
});
addSecretButton.addEventListener("click", () => {
if (!selectedEntry()) return;
openSecretModalPanel("create");
});
newEntryButton.addEventListener("click", () => {
openEntryModalPanel();
});
loginButton.addEventListener("click", doDemoLogin);
logoutButton.addEventListener("click", doLogout);
manageDevicesButton.addEventListener("click", openDevices);
openMcpModalButton.addEventListener("click", openMcpDialog);
userTrigger.addEventListener("click", () => userMenu.classList.toggle("hidden"));
closeDeviceModal.addEventListener("click", () => deviceModal.classList.add("hidden"));
closeMcpModal.addEventListener("click", () => mcpModal.classList.add("hidden"));
closeEntryModal.addEventListener("click", closeEntryModalPanel);
entryModalCancel.addEventListener("click", closeEntryModalPanel);
entryModalSave.addEventListener("click", async () => {
try {
await createNewEntry();
} catch (error) {
window.alert(String(error));
}
});
closeSecretModal.addEventListener("click", closeSecretModalPanel);
secretModalCancel.addEventListener("click", closeSecretModalPanel);
secretModalSave.addEventListener("click", async () => {
try {
await saveSecretModalState();
} catch (error) {
window.alert(String(error));
}
});
closeHistoryModal.addEventListener("click", closeHistoryModalPanel);
deviceModal.addEventListener("click", (event) => {
if (event.target === deviceModal) {
deviceModal.classList.add("hidden");
}
});
mcpModal.addEventListener("click", (event) => {
if (event.target === mcpModal) {
mcpModal.classList.add("hidden");
}
});
entryModal.addEventListener("click", (event) => {
if (event.target === entryModal) {
closeEntryModalPanel();
}
});
secretModal.addEventListener("click", (event) => {
if (event.target === secretModal) {
closeSecretModalPanel();
}
});
historyModal.addEventListener("click", (event) => {
if (event.target === historyModal) {
closeHistoryModalPanel();
}
});
mcpIntegrationList.addEventListener("click", async (event) => {
const button = event.target.closest("[data-apply-target]");
if (!button || !invoke) return;
button.disabled = true;
button.classList.add("is-loading");
try {
const result = await invoke("apply_mcp_config", {
target: button.dataset.applyTarget,
});
renderMcpDialog(result.data);
} finally {
button.disabled = false;
button.classList.remove("is-loading");
}
});
secretList.addEventListener("click", async (event) => {
const button = event.target.closest("[data-secret-action]");
if (!button) return;
const secretId = button.dataset.secretId;
const action = button.dataset.secretAction;
if (!secretId) return;
try {
if (action === "toggle") {
await toggleSecretVisibility(secretId);
return;
}
if (action === "copy") {
await copySecretValue(secretId, button);
return;
}
if (action === "edit") {
await beginEditSecret(secretId);
return;
}
if (action === "history") {
await openSecretHistory(secretId);
return;
}
if (action === "delete") {
if (!window.confirm("确定要删除这个密钥吗?")) return;
await invoke("delete_secret", { secretId });
await loadEntryDetail(selectedEntry().id);
}
} catch (error) {
window.alert(String(error));
}
});
historyList.addEventListener("click", async (event) => {
const button = event.target.closest("[data-history-version]");
if (!button || !appState.historySecretId) return;
if (!window.confirm(`确定回滚到 v${button.dataset.historyVersion} 吗?`)) return;
try {
const detail = await invoke("rollback_secret", {
secretId: appState.historySecretId,
version: Number(button.dataset.historyVersion),
historyId: Number(button.dataset.historyId),
});
closeHistoryModalPanel();
applyUpdatedDetail(detail);
} catch (error) {
window.alert(String(error));
}
});
copyMcpConfigButton.addEventListener("click", async () => {
if (!appState.mcpJson) return;
await navigator.clipboard.writeText(appState.mcpJson);
copyMcpConfigButtonLabel.textContent = "已复制";
setTimeout(() => {
copyMcpConfigButtonLabel.textContent = "复制";
}, 1200);
});
document.addEventListener("click", (event) => {
if (!userTrigger.contains(event.target) && !userMenu.contains(event.target)) {
userMenu.classList.add("hidden");
}
});
bootstrap();