Files
secrets/crates/secrets-mcp/templates/entries.html
agent 6fde982c20
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m6s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
refactor(entries): 将编辑弹窗中的密文管理功能移到查看密文弹窗
- 编辑弹窗移除密文区域(重命名、类型修改、解绑)
- 查看密文弹窗增加:重命名(带 debounce 校验)、类型选择、解绑、保存
- 列表行密文 chips 保留只读展示,移除解绑按钮
- 简化编辑弹窗保存逻辑,不再处理密文变更
2026-04-07 13:25:33 +08:00

1455 lines
62 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — 条目</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #58a6ff; --accent-hover: #79b8ff;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px; }
.lang-btn.active { background: var(--border); color: var(--text); }
.btn-sign-out {
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { background: var(--surface2); }
.main { padding: 32px 24px 40px; flex: 1; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 1480px; margin: 0 auto; }
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
.folder-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.folder-tab {
text-decoration: none;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
background: var(--bg);
}
.folder-tab:hover { color: var(--text); border-color: var(--text-muted); }
.folder-tab.active {
background: rgba(88,166,255,0.12);
border-color: rgba(88,166,255,0.35);
color: var(--text);
}
.filter-bar {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
margin-bottom: 20px; padding: 16px; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px;
}
.filter-field { display: flex; flex-direction: column; gap: 6px; min-width: 140px; flex: 1; }
.filter-field label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
.filter-field input {
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none; width: 100%;
}
.filter-field select {
background-color: var(--surface);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5l3 3 3-3' fill='none' stroke='%238b949e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 12px 12px;
border: 1px solid var(--border); border-radius: 6px;
color: var(--text);
padding: 8px 2.8rem 8px 10px;
font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none; width: 100%;
box-sizing: border-box;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.filter-field input:focus,
.filter-field select:focus { border-color: var(--accent); }
.filter-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.btn-filter {
padding: 8px 16px; border-radius: 6px; border: none; background: var(--accent); color: #0d1117;
font-size: 13px; font-weight: 600; cursor: pointer;
}
.btn-filter:hover { background: var(--accent-hover); }
.btn-clear {
padding: 8px 14px; border-radius: 6px; border: 1px solid var(--border); background: transparent;
color: var(--text-muted); font-size: 13px; text-decoration: none; cursor: pointer;
}
.btn-clear:hover { background: var(--surface2); color: var(--text); }
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
.table-wrap {
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
}
table {
width: 100%;
min-width: 960px;
border-collapse: separate;
border-spacing: 0;
}
th, td { text-align: left; vertical-align: middle; padding: 12px 10px; border-top: 1px solid var(--border); }
th {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
position: sticky;
top: 0;
z-index: 2;
background: var(--surface);
text-align: center;
vertical-align: middle;
}
td { font-size: 13px; line-height: 1.45; }
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
.mono { font-family: 'JetBrains Mono', monospace; }
.col-type { min-width: 108px; width: 1%; text-align: center; vertical-align: middle; }
.col-name { min-width: 180px; max-width: 260px; text-align: center; vertical-align: middle; }
.col-tags { min-width: 160px; max-width: 220px; }
.col-secrets { min-width: 220px; max-width: 420px; vertical-align: middle; }
.col-secrets .secret-list { max-height: 120px; overflow: auto; }
.col-actions { min-width: 132px; width: 1%; text-align: center; vertical-align: middle; }
.cell-name, .cell-tags-val {
overflow-wrap: anywhere;
word-break: break-word;
}
.cell-notes { min-width: 260px; max-width: 360px; }
.notes-scroll {
height: calc(1.5em * 2 + 16px);
min-height: calc(1.5em * 2 + 16px);
overflow: auto;
resize: vertical;
white-space: pre-wrap;
word-break: break-word;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
}
.detail {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
max-width: 360px; max-height: 120px; overflow: auto;
}
.col-actions { white-space: nowrap; }
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; align-items: center; }
.secret-list { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; }
.secret-chip {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
background: var(--surface2);
font-family: 'JetBrains Mono', monospace;
max-width: 100%;
min-width: 0;
}
.secret-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secret-type {
color: var(--text-muted);
border-left: 1px solid var(--border);
padding-left: 6px;
}
.btn-unlink-secret {
border: none;
background: transparent;
color: #f85149;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0;
}
.secret-edit-row {
display: flex;
flex-direction: column;
gap: 0;
padding: 4px 0;
}
.secret-edit-main {
display: grid;
grid-template-columns: 120px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.secret-name-input {
width: 100%;
min-width: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 6px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
outline: none;
}
.secret-name-input:focus {
border-color: var(--accent);
}
.secret-name-input.invalid {
border-color: #f85149;
}
.secret-name-input.valid {
border-color: #3fb950;
}
.secret-type-select {
width: 100%;
min-width: 0;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 6px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
outline: none;
cursor: pointer;
}
.secret-type-select:focus {
border-color: var(--accent);
}
.secret-type-select.invalid {
border-color: #f85149;
}
.btn-unlink-secret {
border: none;
background: transparent;
color: #f85149;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 4px;
}
.secret-name-status {
font-size: 11px;
line-height: 1.3;
margin-top: 2px;
color: var(--text-muted);
}
.secret-name-status:empty {
display: none;
}
.secret-name-status.checking {
color: var(--accent);
}
.secret-name-status.error {
color: #f85149;
}
.secret-name-status.success {
color: #3fb950;
}
.btn-row {
padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer;
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
font-family: inherit;
}
.btn-row:hover { color: var(--text); border-color: var(--text-muted); }
.btn-row.danger:hover { border-color: #f85149; color: #f85149; }
.modal-overlay {
position: fixed; inset: 0; background: rgba(1, 4, 9, 0.65); z-index: 200;
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal-overlay[hidden] { display: none !important; }
.modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 22px; width: 100%; max-width: 520px; max-height: 90vh; overflow: auto;
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
}
.modal.modal-wide {
max-width: 800px;
}
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; }
.modal-field { margin-bottom: 12px; }
.modal-field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
.modal-field input, .modal-field textarea {
width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none;
}
.modal-field textarea { resize: vertical; }
#edit-notes {
height: calc(1.5em * 2 + 16px);
min-height: calc(1.5em * 2 + 16px);
}
.modal-field textarea.metadata-edit { min-height: 140px; }
.modal-readonly-value {
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text-muted); padding: 8px 10px; font-size: 13px;
font-family: 'JetBrains Mono', monospace;
}
.modal-secrets .secret-list { display: flex; flex-direction: column; gap: 10px; }
.modal-error { color: #f85149; font-size: 12px; margin-bottom: 10px; display: none; }
.modal-error.visible { display: block; }
.modal-footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.btn-modal { padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; font-family: inherit; border: 1px solid var(--border); background: transparent; color: var(--text); }
.btn-modal.primary { background: var(--accent); color: #0d1117; border-color: transparent; font-weight: 600; }
.btn-modal.primary:hover { background: var(--accent-hover); }
.btn-modal.danger { border-color: #f85149; color: #f85149; }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
.main { padding: 20px 12px 28px; }
.card { padding: 16px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
.table-wrap { max-height: none; border: none; background: transparent; }
table, thead, tbody, th, td, tr { display: block; min-width: 0; width: 100%; }
thead { display: none; }
tr { border-top: 1px solid var(--border); padding: 12px 0; }
td { border-top: none; padding: 6px 0; max-width: none; }
td::before {
display: block; color: var(--text-muted); font-size: 11px;
margin-bottom: 4px; text-transform: uppercase;
content: attr(data-label);
}
.col-name, .col-type, .col-actions { text-align: left; }
th, td { vertical-align: top; }
.row-actions { justify-content: flex-start; }
.detail, .notes-scroll, .secret-list { max-width: none; }
}
.pagination {
display: flex; align-items: center; gap: 8px; margin-top: 20px;
justify-content: center; padding: 12px 0;
}
.page-btn {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text); text-decoration: none;
font-size: 13px; cursor: pointer;
}
.page-btn:hover { background: var(--surface2); }
.page-btn-disabled {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-muted); font-size: 13px;
opacity: 0.5; cursor: not-allowed;
}
.page-info {
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
}
.view-secret-row {
display: flex; flex-direction: column; gap: 4px; padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.view-secret-row:last-child { border-bottom: none; }
.view-secret-header {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.view-secret-name {
font-family: 'JetBrains Mono', monospace; font-size: 12px;
color: var(--text); font-weight: 600;
}
.view-secret-type {
font-family: 'JetBrains Mono', monospace; font-size: 11px;
color: var(--text-muted); background: var(--surface2);
border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px;
}
.view-secret-actions { margin-left: auto; display: flex; gap: 6px; }
.view-secret-value-wrap { position: relative; }
.view-secret-value {
font-family: 'JetBrains Mono', monospace; font-size: 12px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 7px 10px; word-break: break-all; white-space: pre-wrap;
max-height: 140px; overflow: auto; color: var(--text); line-height: 1.5;
}
.view-secret-value.masked { letter-spacing: 2px; user-select: none; filter: blur(4px); }
.btn-icon {
padding: 3px 8px; border-radius: 5px; font-size: 11px; cursor: pointer;
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
font-family: inherit;
}
.btn-icon:hover { color: var(--text); border-color: var(--text-muted); }
.view-locked-msg {
font-size: 13px; color: var(--text-muted); padding: 16px 0;
line-height: 1.6; text-align: center;
}
.view-locked-msg a { color: var(--accent); }
.view-secret-name-wrap { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex: 1; min-width: 0; }
.view-secret-name-input {
width: 180px; max-width: 100%; background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; color: var(--text); padding: 2px 8px; font-size: 12px;
font-family: 'JetBrains Mono', monospace; outline: none;
}
.view-secret-name-input:focus { border-color: var(--accent); }
.view-secret-type-select {
background: var(--surface2); border: 1px solid var(--border); border-radius: 4px;
color: var(--text); padding: 2px 6px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; outline: none; cursor: pointer;
}
.view-secret-type-select:focus { border-color: var(--accent); }
.btn-view-edit { color: var(--accent); }
.btn-view-save { color: #3fb950; }
.btn-view-cancel { color: var(--text-muted); }
.btn-view-unlink { color: #f85149; font-size: 14px; }
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
<a href="/entries" class="sidebar-link active" data-i18n="navEntries">条目</a>
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<div class="lang-bar">
<button class="lang-btn" onclick="setLang('zh-CN')"></button>
<button class="lang-btn" onclick="setLang('zh-TW')"></button>
<button class="lang-btn" onclick="setLang('en')">EN</button>
</div>
<form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out" data-i18n="signOut">退出</button>
</form>
</div>
<main class="main">
<section class="card">
<div class="card-title" data-i18n="entriesTitle">我的条目</div>
<div class="folder-tabs">
{% for tab in folder_tabs %}
<a href="{{ tab.href }}" class="folder-tab{% if tab.active %} active{% endif %}" {% if loop.first %}data-all-tab="1" data-count="{{ tab.count }}"{% endif %}>
{{ tab.name }} ({{ tab.count }})
</a>
{% endfor %}
</div>
<form class="filter-bar" method="get" action="/entries">
{% if !filter_folder.is_empty() %}
<input type="hidden" name="folder" value="{{ filter_folder }}">
{% endif %}
<div class="filter-field">
<label for="filter-name" data-i18n="filterNameLabel">名称</label>
<input id="filter-name" name="name" type="text" value="{{ filter_name }}" data-i18n-ph="filterNamePlaceholder" placeholder="输入关键字" autocomplete="off">
</div>
<div class="filter-field">
<label for="filter-type" data-i18n="filterTypeLabel">类型</label>
<select id="filter-type" name="type" onchange="this.form.requestSubmit();">
<option value="" data-i18n="filterTypeAll">全部</option>
{% for t in type_options %}
<option value="{{ t }}"{% if filter_type.as_str() == t.as_str() %} selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="filter-actions">
<button type="submit" class="btn-filter" data-i18n="filterSubmit">筛选</button>
<a href="/entries" class="btn-clear" data-i18n="filterClear">清空</a>
</div>
</form>
{% if entries.is_empty() %}
<div class="empty" data-i18n="emptyEntries">暂无条目。</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th data-i18n="colName">名称</th>
<th data-i18n="colType">类型</th>
<th data-i18n="colNotes">备注</th>
<th data-i18n="colTags">标签</th>
<th data-i18n="colSecrets">密文</th>
<th data-i18n="colActions">操作</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr data-entry-id="{{ entry.id }}" data-entry-folder="{{ entry.folder }}" data-entry-metadata="{{ entry.metadata_json }}" data-entry-secrets="{{ entry.secrets_json }}" data-updated-at="{{ entry.updated_at_iso }}">
<td class="col-name mono cell-name" data-label="名称">{{ entry.name }}</td>
<td class="col-type mono cell-type" data-label="类型">{{ entry.entry_type }}</td>
<td class="col-notes cell-notes" data-label="备注">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
<td class="col-tags mono cell-tags-val" data-label="标签">{{ entry.tags }}</td>
<td class="col-secrets" data-label="密文">
<div class="secret-list">
{% for s in entry.secrets %}
<span class="secret-chip">
<span class="secret-name" title="{{ s.name }}">{{ s.name }}</span>
<span class="secret-type">{{ s.secret_type }}</span>
</span>
{% endfor %}
</div>
</td>
<td class="col-actions" data-label="操作">
<div class="row-actions">
<button type="button" class="btn-row btn-view-secrets" data-i18n="rowView">查看密文</button>
<button type="button" class="btn-row btn-edit" data-i18n="rowEdit">编辑条目</button>
<button type="button" class="btn-row danger btn-del" data-i18n="rowDelete">删除</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_count > 0 %}
<div class="pagination">
{% if current_page > 1 %}
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
{% else %}
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
{% endif %}
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
{% if current_page < total_pages %}
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
{% else %}
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
{% endif %}
</div>
{% endif %}
{% endif %}
</section>
</main>
</div>
</div>
<div id="edit-overlay" class="modal-overlay" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="edit-title">
<div class="modal-title" id="edit-title" data-i18n="modalTitle">编辑条目信息</div>
<div id="edit-error" class="modal-error"></div>
<div class="modal-field"><label for="edit-name" data-i18n="modalName">名称</label><input id="edit-name" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-type" data-i18n="modalType">类型</label><input id="edit-type" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-folder" data-i18n="modalFolder">文件夹</label><input id="edit-folder" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-notes" data-i18n="modalNotes">备注</label><textarea id="edit-notes"></textarea></div>
<div class="modal-field"><label for="edit-tags" data-i18n="modalTags">标签(逗号分隔)</label><input id="edit-tags" type="text" autocomplete="off"></div>
<div class="modal-field"><label data-i18n="modalUpdated">更新</label><div id="edit-updated-at" class="modal-readonly-value" aria-live="polite"></div></div>
<div class="modal-field"><label for="edit-metadata" data-i18n="modalMetadata">元数据JSON 对象)</label><textarea id="edit-metadata" class="metadata-edit"></textarea></div>
<div class="modal-footer">
<button type="button" class="btn-modal" id="edit-cancel" data-i18n="modalCancel">取消</button>
<button type="button" class="btn-modal primary" id="edit-save" data-i18n="modalSave">保存</button>
</div>
</div>
</div>
<div id="delete-overlay" class="modal-overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="delete-title">
<div class="modal-title" id="delete-title" data-i18n="deleteTitle">确认删除</div>
<div id="delete-error" class="modal-error"></div>
<div class="modal-field">
<div id="delete-message" style="font-size:14px;line-height:1.6;color:var(--text)"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn-modal" id="delete-cancel" data-i18n="modalCancel">取消</button>
<button type="button" class="btn-modal danger" id="delete-confirm" data-i18n="deleteConfirm">删除</button>
</div>
</div>
</div>
<div id="view-overlay" class="modal-overlay" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="view-title">
<div class="modal-title" id="view-title" data-i18n="viewTitle">查看条目密文</div>
<div id="view-entry-name" style="font-size:13px;color:var(--text-muted);margin-bottom:14px;font-family:'JetBrains Mono',monospace;"></div>
<div id="view-body"></div>
<div class="modal-footer">
<button type="button" class="btn-modal" id="view-close" data-i18n="modalCancel">关闭</button>
</div>
</div>
</div>
<script src="/static/i18n.js"></script>
<script id="secret-type-options" type="application/json">{{ secret_type_options_json|safe }}</script>
<script>
var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-options').textContent);
(function () {
I18N_PAGE = {
'zh-CN': {
pageTitle: 'Secrets — 条目',
entriesTitle: '我的条目',
allTab: '全部',
filterNameLabel: '名称',
filterNamePlaceholder: '输入关键字',
filterTypeLabel: '类型',
filterTypeAll: '全部',
filterSubmit: '筛选',
filterClear: '清空',
emptyEntries: '暂无条目。',
colName: '名称',
colType: '类型',
colNotes: '备注',
colTags: '标签',
colSecrets: '密文',
colActions: '操作',
rowEdit: '编辑条目',
rowDelete: '删除',
modalTitle: '编辑条目信息',
modalName: '名称',
modalType: '类型',
modalFolder: '文件夹',
modalNotes: '备注',
modalTags: '标签(逗号分隔)',
modalUpdated: '更新',
modalMetadata: '元数据JSON 对象)',
modalSecrets: '密文',
modalCancel: '取消',
modalSave: '保存',
deleteTitle: '确认删除',
deleteConfirm: '删除',
deleteMessage: '确定删除条目「{name}」?此操作不可撤销。',
mobileLabelName: '名称',
mobileLabelType: '类型',
mobileLabelNotes: '备注',
mobileLabelTags: '标签',
mobileLabelSecrets: '密文',
mobileLabelActions: '操作',
errInvalidJson: '元数据不是合法 JSON',
errMetadataObject: '元数据必须是 JSON 对象',
confirmDeleteEntry: '确定删除条目「{name}」?',
confirmUnlinkSecret: '确定解除密文关联「{name}」?',
unlinkTitle: '解除关联',
renameSecretTitle: '重命名密文',
renameSecretPlaceholder: '输入新名称',
errRenameSecret: '重命名失败:{error}',
checkingSecretName: '检查中...',
secretNameAvailable: '名称可用',
secretNameTaken: '该名称已被使用',
secretNameInvalid: '名称不合法',
secretNameCheckError: '校验失败,请重试',
secretNameFixBeforeSave: '请先修复密文名称校验问题后再保存',
secretTypePlaceholder: '选择类型',
secretTypeInvalid: '类型不能为空',
prevPage: '上一页',
nextPage: '下一页',
rowView: '查看密文',
viewTitle: '查看条目密文',
viewNoSecrets: '该条目没有关联的密文字段。',
viewLockedMsg: '请先前往 <a href="/dashboard">MCP 配置页</a> 解锁密码短语,然后再查看密文。',
viewDecryptError: '解密失败,请确认密码短语与加密时一致。',
viewCopy: '复制',
viewCopied: '已复制',
viewShow: '显示',
viewHide: '隐藏',
viewLoading: '解密中…',
viewSaveChanges: '保存更改',
viewChangesSaved: '已保存',
viewUnlinkConfirm: '确定解除密文关联「{name}」?',
},
'zh-TW': {
pageTitle: 'Secrets — 條目',
entriesTitle: '我的條目',
allTab: '全部',
filterNameLabel: '名稱',
filterNamePlaceholder: '輸入關鍵字',
filterTypeLabel: '類型',
filterTypeAll: '全部',
filterSubmit: '篩選',
filterClear: '清除',
emptyEntries: '暫無條目。',
colName: '名稱',
colType: '類型',
colNotes: '備註',
colTags: '標籤',
colSecrets: '密文',
colActions: '操作',
rowEdit: '編輯條目',
rowDelete: '刪除',
modalTitle: '編輯條目資訊',
modalName: '名稱',
modalType: '類型',
modalFolder: '資料夾',
modalNotes: '備註',
modalTags: '標籤(逗號分隔)',
modalUpdated: '更新時間',
modalMetadata: '中繼資料JSON 物件)',
modalSecrets: '密文',
modalCancel: '取消',
modalSave: '儲存',
deleteTitle: '確認刪除',
deleteConfirm: '刪除',
deleteMessage: '確定刪除條目「{name}」?此操作不可復原。',
mobileLabelName: '名稱',
mobileLabelType: '類型',
mobileLabelNotes: '備註',
mobileLabelTags: '標籤',
mobileLabelSecrets: '密文',
mobileLabelActions: '操作',
errInvalidJson: '中繼資料不是合法 JSON',
errMetadataObject: '中繼資料必須是 JSON 物件',
confirmDeleteEntry: '確定刪除條目「{name}」?',
confirmUnlinkSecret: '確定解除密文關聯「{name}」?',
unlinkTitle: '解除關聯',
renameSecretTitle: '重新命名密文',
renameSecretPlaceholder: '輸入新名稱',
errRenameSecret: '重新命名失敗:{error}',
checkingSecretName: '檢查中...',
secretNameAvailable: '名稱可用',
secretNameTaken: '該名稱已被使用',
secretNameInvalid: '名稱不合法',
secretNameCheckError: '校驗失敗,請重試',
secretNameFixBeforeSave: '請先修復密文名稱校驗問題後再儲存',
secretTypePlaceholder: '選擇類型',
secretTypeInvalid: '類型不能為空',
prevPage: '上一頁',
nextPage: '下一頁',
rowView: '查看密文',
viewTitle: '查看條目密文',
viewNoSecrets: '該條目沒有關聯的密文欄位。',
viewLockedMsg: '請先前往 <a href="/dashboard">MCP 設定頁</a> 解鎖密碼短語,再查看密文。',
viewDecryptError: '解密失敗,請確認密碼短語與加密時一致。',
viewCopy: '複製',
viewCopied: '已複製',
viewShow: '顯示',
viewHide: '隱藏',
viewLoading: '解密中…',
viewSaveChanges: '儲存變更',
viewChangesSaved: '已儲存',
viewUnlinkConfirm: '確定解除密文關聯「{name}」?',
},
en: {
pageTitle: 'Secrets — Entries',
entriesTitle: 'My entries',
allTab: 'All',
filterNameLabel: 'Name',
filterNamePlaceholder: 'Enter keywords',
filterTypeLabel: 'Type',
filterTypeAll: 'All',
filterSubmit: 'Filter',
filterClear: 'Clear',
emptyEntries: 'No entries.',
colName: 'Name',
colType: 'Type',
colNotes: 'Notes',
colTags: 'Tags',
colSecrets: 'Secrets',
colActions: 'Actions',
rowEdit: 'Edit entry',
rowDelete: 'Delete',
modalTitle: 'Edit entry details',
modalName: 'Name',
modalType: 'Type',
modalFolder: 'Folder',
modalNotes: 'Notes',
modalTags: 'Tags (comma separated)',
modalUpdated: 'Updated',
modalMetadata: 'Metadata (JSON object)',
modalSecrets: 'Secrets',
modalCancel: 'Cancel',
modalSave: 'Save',
deleteTitle: 'Confirm Delete',
deleteConfirm: 'Delete',
deleteMessage: 'Are you sure you want to delete entry "{name}"? This action cannot be undone.',
mobileLabelName: 'Name',
mobileLabelType: 'Type',
mobileLabelNotes: 'Notes',
mobileLabelTags: 'Tags',
mobileLabelSecrets: 'Secrets',
mobileLabelActions: 'Actions',
errInvalidJson: 'Metadata is not valid JSON',
errMetadataObject: 'Metadata must be a JSON object',
confirmDeleteEntry: 'Delete entry "{name}"?',
confirmUnlinkSecret: 'Unlink secret "{name}"?',
unlinkTitle: 'Unlink',
renameSecretTitle: 'Rename secret',
renameSecretPlaceholder: 'Enter new name',
errRenameSecret: 'Failed to rename: {error}',
checkingSecretName: 'Checking...',
secretNameAvailable: 'Name available',
secretNameTaken: 'Name already taken',
secretNameInvalid: 'Invalid name',
secretNameCheckError: 'Validation failed, please retry',
secretNameFixBeforeSave: 'Fix secret name validation errors before saving',
secretTypePlaceholder: 'Select type',
secretTypeInvalid: 'Type cannot be empty',
prevPage: 'Previous',
nextPage: 'Next',
rowView: 'View secrets',
viewTitle: 'View entry secrets',
viewNoSecrets: 'This entry has no associated secret fields.',
viewLockedMsg: 'Please go to the <a href="/dashboard">MCP config page</a> to unlock your passphrase first.',
viewDecryptError: 'Decryption failed. Please verify your passphrase matches the one used when encrypting.',
viewCopy: 'Copy',
viewCopied: 'Copied',
viewShow: 'Show',
viewHide: 'Hide',
viewLoading: 'Decrypting…',
viewSaveChanges: 'Save changes',
viewChangesSaved: 'Saved',
viewUnlinkConfirm: 'Unlink secret "{name}"?',
}
};
window.applyPageLang = function () {
var allTab = document.querySelector('.folder-tab[data-all-tab="1"]');
if (allTab) {
var count = allTab.getAttribute('data-count') || '0';
allTab.textContent = t('allTab') + ' (' + count + ')';
}
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
var map = {
'.col-name': 'mobileLabelName',
'.col-type': 'mobileLabelType',
'.col-notes': 'mobileLabelNotes',
'.col-tags': 'mobileLabelTags',
'.col-secrets': 'mobileLabelSecrets',
'.col-actions': 'mobileLabelActions'
};
Object.keys(map).forEach(function (sel) {
var td = tr.querySelector(sel);
if (td) td.setAttribute('data-label', t(map[sel]));
});
});
};
var editOverlay = document.getElementById('edit-overlay');
var editError = document.getElementById('edit-error');
var editFolder = document.getElementById('edit-folder');
var editType = document.getElementById('edit-type');
var editName = document.getElementById('edit-name');
var editNotes = document.getElementById('edit-notes');
var editTags = document.getElementById('edit-tags');
var editMetadata = document.getElementById('edit-metadata');
var editUpdatedAt = document.getElementById('edit-updated-at');
var deleteOverlay = document.getElementById('delete-overlay');
var deleteError = document.getElementById('delete-error');
var deleteMessage = document.getElementById('delete-message');
var currentEntryId = null;
var pendingDeleteId = null;
// ── View secrets modal ────────────────────────────────────────────────────
var viewOverlay = document.getElementById('view-overlay');
var viewEntryName = document.getElementById('view-entry-name');
var viewBody = document.getElementById('view-body');
function closeView() {
viewOverlay.hidden = true;
viewBody.innerHTML = '';
viewEntryName.textContent = '';
}
document.getElementById('view-close').addEventListener('click', closeView);
viewOverlay.addEventListener('click', function (e) {
if (e.target === viewOverlay) closeView();
});
function renderViewSecrets(secrets, secretSchema) {
viewBody.innerHTML = '';
var names = Object.keys(secrets);
if (names.length === 0) {
var msg = document.createElement('div');
msg.className = 'view-locked-msg';
msg.textContent = t('viewNoSecrets');
viewBody.appendChild(msg);
return;
}
var schemaMap = {};
(secretSchema || []).forEach(function (s) { schemaMap[s.name] = s; });
names.forEach(function (name) {
var raw = secrets[name];
var valueStr = (raw === null || raw === undefined) ? '' :
(typeof raw === 'object') ? JSON.stringify(raw, null, 2) : String(raw);
var isPassword = (name === 'password' || name === 'passwd' || name === 'secret');
var masked = isPassword;
var schema = schemaMap[name] || {};
var secretId = schema.id || '';
var secretType = schema.secret_type || 'text';
var originalName = name;
var hasChanges = false;
var row = document.createElement('div');
row.className = 'view-secret-row';
row.setAttribute('data-secret-id', secretId);
row.setAttribute('data-original-name', originalName);
var header = document.createElement('div');
header.className = 'view-secret-header';
var nameWrap = document.createElement('div');
nameWrap.className = 'view-secret-name-wrap';
var nameSpan = document.createElement('span');
nameSpan.className = 'view-secret-name';
nameSpan.textContent = name;
var nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'view-secret-name-input';
nameInput.value = name;
nameInput.placeholder = t('renameSecretPlaceholder');
nameInput.setAttribute('data-original-name', originalName);
nameInput.hidden = true;
var typeBadge = document.createElement('span');
typeBadge.className = 'view-secret-type';
typeBadge.textContent = secretType;
var typeSelect = document.createElement('select');
typeSelect.className = 'view-secret-type-select';
typeSelect.hidden = true;
SECRET_TYPE_OPTIONS.forEach(function (opt) {
var option = document.createElement('option');
option.value = opt;
option.textContent = opt;
if (opt === secretType) option.selected = true;
typeSelect.appendChild(option);
});
if (SECRET_TYPE_OPTIONS.indexOf(secretType) === -1 && secretType) {
var fallback = document.createElement('option');
fallback.value = secretType;
fallback.textContent = secretType;
fallback.selected = true;
typeSelect.appendChild(fallback);
}
nameWrap.appendChild(nameSpan);
nameWrap.appendChild(nameInput);
nameWrap.appendChild(typeBadge);
nameWrap.appendChild(typeSelect);
header.appendChild(nameWrap);
var actions = document.createElement('div');
actions.className = 'view-secret-actions';
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn-icon btn-view-edit';
editBtn.textContent = '✎';
editBtn.title = t('renameSecretTitle');
var saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'btn-icon btn-view-save';
saveBtn.textContent = t('viewSaveChanges');
saveBtn.hidden = true;
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn-icon btn-view-cancel';
cancelBtn.textContent = t('modalCancel');
cancelBtn.hidden = true;
if (isPassword) {
var toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'btn-icon btn-toggle-mask';
toggleBtn.textContent = t('viewShow');
toggleBtn.addEventListener('click', function () {
masked = !masked;
valueEl.classList.toggle('masked', masked);
toggleBtn.textContent = masked ? t('viewShow') : t('viewHide');
});
actions.appendChild(toggleBtn);
}
var copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'btn-icon';
copyBtn.textContent = t('viewCopy');
copyBtn.addEventListener('click', function () {
navigator.clipboard.writeText(valueStr).then(function () {
copyBtn.textContent = t('viewCopied');
setTimeout(function () { copyBtn.textContent = t('viewCopy'); }, 1800);
}).catch(function () {});
});
actions.appendChild(copyBtn);
var unlinkBtn = document.createElement('button');
unlinkBtn.type = 'button';
unlinkBtn.className = 'btn-icon btn-view-unlink';
unlinkBtn.textContent = '×';
unlinkBtn.title = t('unlinkTitle');
actions.appendChild(unlinkBtn);
actions.appendChild(editBtn);
actions.appendChild(saveBtn);
actions.appendChild(cancelBtn);
header.appendChild(actions);
row.appendChild(header);
var valueWrap = document.createElement('div');
valueWrap.className = 'view-secret-value-wrap';
var valueEl = document.createElement('div');
valueEl.className = 'view-secret-value' + (masked ? ' masked' : '');
valueEl.textContent = valueStr;
valueWrap.appendChild(valueEl);
row.appendChild(valueWrap);
var nameStatus = document.createElement('div');
nameStatus.className = 'secret-name-status';
nameStatus.setAttribute('data-status', 'idle');
row.appendChild(nameStatus);
viewBody.appendChild(row);
// ── Edit mode toggle ──
function enterEditMode() {
nameSpan.hidden = true;
typeBadge.hidden = true;
nameInput.hidden = false;
typeSelect.hidden = false;
saveBtn.hidden = false;
cancelBtn.hidden = false;
editBtn.hidden = true;
nameInput.focus();
nameInput.select();
}
function exitEditMode() {
nameSpan.hidden = false;
typeBadge.hidden = false;
nameInput.hidden = true;
typeSelect.hidden = true;
saveBtn.hidden = true;
cancelBtn.hidden = true;
editBtn.hidden = false;
nameStatus.textContent = '';
nameStatus.className = 'secret-name-status';
nameInput.value = nameSpan.textContent;
typeSelect.value = typeBadge.textContent;
hasChanges = false;
}
editBtn.addEventListener('click', enterEditMode);
cancelBtn.addEventListener('click', exitEditMode);
// ── Name validation ──
var debounceTimer = null;
var currentCheck = null;
nameInput.addEventListener('input', function () {
if (debounceTimer) clearTimeout(debounceTimer);
nameStatus.textContent = '';
nameStatus.className = 'secret-name-status';
debounceTimer = setTimeout(function () {
var newName = nameInput.value.trim();
if (!newName || newName === originalName) return;
nameStatus.textContent = t('checkingSecretName');
nameStatus.className = 'secret-name-status checking';
var checkId = Date.now();
currentCheck = checkId;
var params = new URLSearchParams();
params.set('name', newName);
params.set('exclude_secret_id', secretId);
fetch('/api/secrets/check-name?' + params.toString(), { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (currentCheck !== checkId) return;
if (data.ok && data.available) {
nameStatus.textContent = t('secretNameAvailable');
nameStatus.className = 'secret-name-status success';
hasChanges = true;
} else {
nameStatus.textContent = data.error || t('secretNameTaken');
nameStatus.className = 'secret-name-status error';
hasChanges = false;
}
})
.catch(function () {
if (currentCheck !== checkId) return;
nameStatus.textContent = t('secretNameCheckError');
nameStatus.className = 'secret-name-status error';
hasChanges = false;
});
}, 300);
});
nameInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); saveBtn.click(); }
if (e.key === 'Escape') { cancelBtn.click(); }
});
// ── Save ──
saveBtn.addEventListener('click', function () {
var newName = nameInput.value.trim();
var newType = typeSelect.value;
if (!newName) { nameStatus.textContent = t('secretNameInvalid'); nameStatus.className = 'secret-name-status error'; return; }
if (!newType) { nameStatus.textContent = t('secretTypeInvalid'); nameStatus.className = 'secret-name-status error'; return; }
var patchBody = {};
if (newName !== originalName) patchBody.name = newName;
if (newType !== secretType) patchBody.type = newType;
if (Object.keys(patchBody).length === 0) { exitEditMode(); return; }
saveBtn.textContent = '...';
fetch('/api/secrets/' + encodeURIComponent(secretId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(patchBody)
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).then(function () {
nameSpan.textContent = newName;
typeBadge.textContent = newType;
originalName = newName;
nameInput.setAttribute('data-original-name', newName);
saveBtn.textContent = t('viewChangesSaved');
setTimeout(function () { exitEditMode(); saveBtn.textContent = t('viewSaveChanges'); }, 1200);
// Update table row chip
var tableRow = document.querySelector('tr[data-entry-id="' + viewBody.getAttribute('data-entry-id') + '"]');
if (tableRow) {
var chip = tableRow.querySelector('.secret-chip .secret-name');
if (chip && chip.textContent === name) chip.textContent = newName;
}
}).catch(function (err) {
nameStatus.textContent = err.message || String(err);
nameStatus.className = 'secret-name-status error';
saveBtn.textContent = t('viewSaveChanges');
});
});
// ── Unlink ──
unlinkBtn.addEventListener('click', function () {
if (!confirm(tf('viewUnlinkConfirm', { name: nameSpan.textContent }))) return;
fetch('/api/entries/' + encodeURIComponent(viewBody.getAttribute('data-entry-id')) + '/secrets/' + encodeURIComponent(secretId), {
method: 'DELETE',
credentials: 'same-origin'
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).then(function () {
row.remove();
if (!viewBody.querySelector('.view-secret-row')) {
viewBody.innerHTML = '';
var msg = document.createElement('div');
msg.className = 'view-locked-msg';
msg.textContent = t('viewNoSecrets');
viewBody.appendChild(msg);
}
// Update table row
var tableRow = document.querySelector('tr[data-entry-id="' + viewBody.getAttribute('data-entry-id') + '"]');
if (tableRow) {
var chip = tableRow.querySelector('.secret-chip');
if (chip) {
var chipName = chip.querySelector('.secret-name');
if (chipName && chipName.textContent === name) chip.remove();
}
}
}).catch(function (err) {
alert(err.message || String(err));
});
});
});
}
function openView(tr) {
var entryId = tr.getAttribute('data-entry-id');
var nameEl = tr.querySelector('.cell-name');
var entryName = nameEl ? nameEl.textContent.trim() : '';
var encKey = sessionStorage.getItem('enc_key');
viewEntryName.textContent = entryName;
viewBody.innerHTML = '';
viewBody.setAttribute('data-entry-id', entryId);
viewOverlay.hidden = false;
if (!encKey) {
var msg = document.createElement('div');
msg.className = 'view-locked-msg';
msg.innerHTML = t('viewLockedMsg');
viewBody.appendChild(msg);
return;
}
var loadingMsg = document.createElement('div');
loadingMsg.className = 'view-locked-msg';
loadingMsg.textContent = t('viewLoading');
viewBody.appendChild(loadingMsg);
var sj = tr.getAttribute('data-entry-secrets') || '[]';
var secretSchema;
try { secretSchema = JSON.parse(sj); } catch (e) { secretSchema = []; }
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/decrypt', {
credentials: 'same-origin',
headers: { 'X-Encryption-Key': encKey }
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).then(function (data) {
renderViewSecrets(data.secrets || {}, secretSchema);
}).catch(function (e) {
viewBody.innerHTML = '';
var errMsg = document.createElement('div');
errMsg.className = 'view-locked-msg';
errMsg.style.color = '#f85149';
errMsg.textContent = e.message || t('viewDecryptError');
viewBody.appendChild(errMsg);
});
}
// ─────────────────────────────────────────────────────────────────────────
function showEditErr(msg) {
editError.textContent = msg || '';
editError.classList.toggle('visible', !!msg);
}
function formatLocalTime(raw) {
if (!raw) return '—';
var d = new Date(raw);
if (!d || isNaN(d.getTime())) return raw;
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
}
function openEdit(tr) {
var id = tr.getAttribute('data-entry-id');
if (!id) return;
currentEntryId = id;
showEditErr('');
editFolder.value = tr.getAttribute('data-entry-folder') || '';
editType.value = tr.querySelector('.cell-type') ? tr.querySelector('.cell-type').textContent.trim() : '';
editName.value = tr.querySelector('.cell-name') ? tr.querySelector('.cell-name').textContent.trim() : '';
editNotes.value = tr.querySelector('.cell-notes-val') ? tr.querySelector('.cell-notes-val').textContent : '';
var tagsText = tr.querySelector('.cell-tags-val') ? tr.querySelector('.cell-tags-val').textContent.trim() : '';
editTags.value = tagsText;
var rawUpdated = tr.getAttribute('data-updated-at');
editUpdatedAt.textContent = formatLocalTime(rawUpdated);
editUpdatedAt.title = rawUpdated ? rawUpdated + ' (UTC)' : '';
var md = tr.getAttribute('data-entry-metadata') || '{}';
try {
var mobj = JSON.parse(md);
editMetadata.value = JSON.stringify(mobj, null, 2);
} catch (err) {
editMetadata.value = md;
}
editOverlay.hidden = false;
}
function closeEdit() {
editOverlay.hidden = true;
currentEntryId = null;
showEditErr('');
editUpdatedAt.textContent = '';
editUpdatedAt.title = '';
}
document.getElementById('edit-cancel').addEventListener('click', closeEdit);
editOverlay.addEventListener('click', function (e) {
if (e.target === editOverlay) closeEdit();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !editOverlay.hidden) closeEdit();
if (e.key === 'Escape' && !deleteOverlay.hidden) closeDelete();
if (e.key === 'Escape' && !viewOverlay.hidden) closeView();
});
function showDeleteErr(msg) {
deleteError.textContent = msg || '';
deleteError.classList.toggle('visible', !!msg);
}
function openDelete(id, name) {
pendingDeleteId = id;
deleteMessage.textContent = tf('deleteMessage', { name: name });
showDeleteErr('');
deleteOverlay.hidden = false;
}
function closeDelete() {
deleteOverlay.hidden = true;
pendingDeleteId = null;
showDeleteErr('');
}
function refreshListAfterSave(entryId, body) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) { window.location.reload(); return; }
var nameCell = tr.querySelector('.cell-name');
if (nameCell) nameCell.textContent = body.name;
var typeCell = tr.querySelector('.cell-type');
if (typeCell) typeCell.textContent = body.type;
var notesCell = tr.querySelector('.cell-notes-val');
if (notesCell) {
if (body.notes) { notesCell.textContent = body.notes; }
else { var notesWrap = tr.querySelector('.cell-notes'); if (notesWrap) notesWrap.innerHTML = ''; }
}
var tagsCell = tr.querySelector('.cell-tags-val');
if (tagsCell) tagsCell.textContent = body.tags.join(', ');
tr.setAttribute('data-entry-folder', body.folder);
tr.setAttribute('data-entry-metadata', JSON.stringify(body.metadata));
}
function refreshListAfterDelete(entryId) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
var folder = tr ? tr.getAttribute('data-entry-folder') : null;
if (tr) tr.remove();
var tbody = document.querySelector('table tbody');
if (tbody && !tbody.querySelector('tr[data-entry-id]')) {
var card = document.querySelector('.card');
if (card) {
var tableWrap = card.querySelector('.table-wrap');
if (tableWrap) tableWrap.remove();
var existingEmpty = card.querySelector('.empty');
if (!existingEmpty) {
var emptyDiv = document.createElement('div');
emptyDiv.className = 'empty';
emptyDiv.setAttribute('data-i18n', 'emptyEntries');
emptyDiv.textContent = t('emptyEntries');
var filterBar = card.querySelector('.filter-bar');
if (filterBar) { card.insertBefore(emptyDiv, filterBar.nextSibling); }
else { card.appendChild(emptyDiv); }
}
}
}
var allTab = document.querySelector('.folder-tab[data-all-tab="1"]');
if (allTab) {
var count = parseInt(allTab.getAttribute('data-count') || '0', 10);
if (count > 0) {
count -= 1;
allTab.setAttribute('data-count', String(count));
allTab.textContent = t('allTab') + ' (' + count + ')';
}
}
if (folder) {
document.querySelectorAll('.folder-tab:not([data-all-tab])').forEach(function (tab) {
if (tab.textContent.trim().indexOf(folder) === 0) {
var m = tab.textContent.match(/\((\d+)\)/);
if (m) {
var c = parseInt(m[1], 10);
if (c > 0) {
c -= 1;
tab.textContent = folder + ' (' + c + ')';
}
}
}
});
}
}
document.getElementById('delete-cancel').addEventListener('click', closeDelete);
deleteOverlay.addEventListener('click', function (e) {
if (e.target === deleteOverlay) closeDelete();
});
document.getElementById('delete-confirm').addEventListener('click', function () {
if (!pendingDeleteId) return;
fetch('/api/entries/' + encodeURIComponent(pendingDeleteId), { method: 'DELETE', credentials: 'same-origin' })
.then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
})
.then(function () {
var deletedId = pendingDeleteId;
closeDelete();
refreshListAfterDelete(deletedId);
})
.catch(function (e) { showDeleteErr(e.message || String(e)); });
});
document.getElementById('edit-save').addEventListener('click', function () {
if (!currentEntryId) return;
var meta;
try {
meta = JSON.parse(editMetadata.value);
} catch (err) {
showEditErr(t('errInvalidJson'));
return;
}
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
showEditErr(t('errMetadataObject'));
return;
}
var tags = editTags.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
var body = {
folder: editFolder.value,
type: editType.value,
name: editName.value.trim(),
notes: editNotes.value,
tags: tags,
metadata: meta
};
showEditErr('');
fetch('/api/entries/' + encodeURIComponent(currentEntryId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body)
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).then(function () {
closeEdit();
refreshListAfterSave(currentEntryId, body);
}).catch(function (e) {
showEditErr(e.message || String(e));
});
});
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
var viewBtn = tr.querySelector('.btn-view-secrets');
if (viewBtn) {
var hasSecrets = tr.querySelectorAll('.secret-chip').length > 0;
if (!hasSecrets) viewBtn.disabled = true;
viewBtn.addEventListener('click', function () { openView(tr); });
}
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
tr.querySelector('.btn-del').addEventListener('click', function () {
var id = tr.getAttribute('data-entry-id');
var nameEl = tr.querySelector('.cell-name');
var name = nameEl ? nameEl.textContent.trim() : '';
if (!id) return;
openDelete(id, name);
});
});
applyLang();
})();
</script>
</body>
</html>