Files
secrets/crates/secrets-mcp/templates/entries.html
agent 089d0b4b58
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m30s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m37s
style(dashboard): move version footer out of card
2026-04-09 17:32:40 +08:00

1571 lines
67 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: #0d1117; color: #c9d1d9; font-family: 'Inter', sans-serif; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
color: #fff; text-decoration: none; padding: 0 10px; }
.sidebar-menu { display: grid; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
.sidebar-link.active {
background: rgba(56,139,253,0.14); color: #fff;
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: transparent; border-bottom: none; padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 44px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 14px; color: #8b949e; }
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
font-size: 12px; cursor: pointer; border-radius: 6px; }
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
.btn-sign-out {
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.main { padding: 16px 16px 24px; flex: 1; }
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
padding: 20px; width: 100%; }
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 8px; color: #fff; }
.card-subtitle { color: #8b949e; font-size: 14px; margin-bottom: 18px; }
.folder-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.folder-tab {
text-decoration: none;
color: #8b949e;
border: 1px solid rgba(240,246,252,0.08);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
background: #0d1117;
}
.folder-tab:hover { color: #c9d1d9; border-color: rgba(240,246,252,0.2); }
.folder-tab.active {
background: rgba(56,139,253,0.14);
border-color: rgba(56,139,253,0.3);
color: #fff;
}
.filter-bar {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
margin-bottom: 18px; padding: 16px; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
border-radius: 12px;
}
.filter-field { display: flex; flex-direction: column; gap: 6px; min-width: 140px; flex: 1; }
.filter-field label { font-size: 12px; color: #8b949e; font-weight: 500; }
.filter-field input {
background: #161b22; border: 1px solid rgba(240,246,252,0.08); border-radius: 8px;
color: #c9d1d9; padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none; width: 100%;
}
.filter-field select {
background-color: #161b22;
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 rgba(240,246,252,0.08); border-radius: 8px;
color: #c9d1d9;
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: rgba(56,139,253,0.5); }
.filter-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.btn-filter {
padding: 8px 16px; border-radius: 10px; border: none; background: #388bfd; color: #fff;
font-size: 13px; font-weight: 600; cursor: pointer;
}
.btn-filter:hover { background: #58a6ff; }
.btn-clear {
padding: 8px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12); background: transparent;
color: #8b949e; font-size: 13px; text-decoration: none; cursor: pointer;
}
.btn-clear:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
.table-wrap {
overflow: auto;
border: 1px solid rgba(240,246,252,0.08);
border-radius: 12px;
background: #0d1117;
}
table {
width: 100%;
min-width: 960px;
border-collapse: separate;
border-spacing: 0;
}
th, td { text-align: left; vertical-align: middle; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); }
th {
color: #8b949e;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
position: sticky;
top: 0;
z-index: 2;
background: #111827;
text-align: center;
vertical-align: middle;
}
td { font-size: 13px; line-height: 1.45; color: #c9d1d9; }
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: #0d1117;
border: 1px solid rgba(240,246,252,0.08);
border-radius: 10px;
font-size: 12px;
}
.detail {
background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
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 rgba(240,246,252,0.08);
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
background: #161b22;
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: #8b949e;
border-left: 1px solid rgba(240,246,252,0.08);
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: #0d1117;
border: 1px solid rgba(240,246,252,0.08);
border-radius: 8px;
color: #c9d1d9;
padding: 6px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
outline: none;
}
.secret-name-input:focus {
border-color: rgba(56,139,253,0.5);
}
.secret-name-input.invalid {
border-color: #f85149;
}
.secret-name-input.valid {
border-color: #3fb950;
}
.secret-type-select {
width: 100%;
min-width: 0;
background: #161b22;
border: 1px solid rgba(240,246,252,0.08);
border-radius: 8px;
color: #c9d1d9;
padding: 6px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
outline: none;
cursor: pointer;
}
.secret-type-select:focus {
border-color: rgba(56,139,253,0.5);
}
.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: #8b949e;
}
.secret-name-status:empty {
display: none;
}
.secret-name-status.checking {
color: #58a6ff;
}
.secret-name-status.error {
color: #f85149;
}
.secret-name-status.success {
color: #3fb950;
}
.btn-row {
padding: 8px 12px; border-radius: 10px; font-size: 13px; cursor: pointer;
border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #8b949e;
font-family: inherit;
}
.btn-row:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.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: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
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: 18px; font-weight: 700; margin-bottom: 14px; color: #fff; }
.modal-field { margin-bottom: 12px; }
.modal-field label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 5px; }
.modal-field input, .modal-field textarea {
width: 100%; background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
color: #c9d1d9; 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: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
color: #8b949e; 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: 10px; font-size: 13px; cursor: pointer; font-family: inherit; border: 1px solid rgba(240,246,252,0.12); background: transparent; color: #c9d1d9; }
.btn-modal:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.btn-modal.primary { background: #388bfd; color: #fff; border-color: transparent; font-weight: 600; }
.btn-modal.primary:hover { background: #58a6ff; }
.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 rgba(240,246,252,0.08);
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 rgba(240,246,252,0.08); padding: 12px 0; }
td { border-top: none; padding: 6px 0; max-width: none; }
td::before {
display: block; color: #8b949e; 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: 12px; margin-top: 18px;
justify-content: center; padding: 12px 0;
}
.page-btn {
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #c9d1d9; text-decoration: none;
font-size: 13px; cursor: pointer;
}
.page-btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.page-btn-disabled {
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #6e7681; font-size: 13px;
opacity: 0.5; cursor: not-allowed;
}
.page-info {
color: #8b949e; 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 rgba(240,246,252,0.08);
}
.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: #c9d1d9; font-weight: 600;
}
.view-secret-type {
font-family: 'JetBrains Mono', monospace; font-size: 11px;
color: #8b949e; background: #161b22;
border: 1px solid rgba(240,246,252,0.08); border-radius: 6px; 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: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
padding: 7px 10px; word-break: break-all; white-space: pre-wrap;
max-height: 140px; overflow: auto; color: #c9d1d9; line-height: 1.5;
}
.view-secret-value.masked { letter-spacing: 2px; user-select: none; filter: blur(4px); }
.btn-icon {
padding: 6px 10px; border-radius: 8px; font-size: 12px; cursor: pointer;
border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #8b949e;
font-family: inherit;
}
.btn-icon:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.view-locked-msg {
font-size: 13px; color: #8b949e; padding: 16px 0;
line-height: 1.6; text-align: center;
}
.view-locked-msg a { color: #58a6ff; }
.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: #0d1117; border: 1px solid rgba(240,246,252,0.08);
border-radius: 8px; color: #c9d1d9; padding: 2px 8px; font-size: 12px;
font-family: 'JetBrains Mono', monospace; outline: none;
}
.view-secret-name-input:focus { border-color: rgba(56,139,253,0.5); }
.view-secret-type-select {
background: #161b22; border: 1px solid rgba(240,246,252,0.08); border-radius: 8px;
color: #c9d1d9; padding: 2px 6px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; outline: none; cursor: pointer;
}
.view-secret-type-select:focus { border-color: rgba(56,139,253,0.5); }
.btn-view-edit { color: #58a6ff; }
.btn-view-save { color: #3fb950; }
.btn-view-cancel { color: #8b949e; }
.btn-view-unlink { color: #f85149; font-size: 14px; }
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo">secrets</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="/trash" class="sidebar-link" data-i18n="navTrash">回收站</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-metadata-query" data-i18n="filterMetadataLabel">元数据值</label>
<input id="filter-metadata-query" name="metadata_query" type="text" value="{{ filter_metadata_query }}" data-i18n-ph="filterMetadataPlaceholder" 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="colRelations">关联</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-entry-parents="{{ entry.parents_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-relations" data-label="关联">
<div class="secret-list">
{% for parent in entry.parents %}
<a class="secret-chip" href="{{ parent.href }}" title="{{ parent.folder }} / {{ parent.name }}">
<span class="secret-name">{{ parent.name }}</span>
<span class="secret-type" data-i18n="relationParentBadge">上级</span>
</a>
{% endfor %}
{% for child in entry.children %}
<a class="secret-chip" href="{{ child.href }}" title="{{ child.folder }} / {{ child.name }}">
<span class="secret-name">{{ child.name }}</span>
<span class="secret-type" data-i18n="relationChildBadge">下级</span>
</a>
{% endfor %}
</div>
</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 %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | 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 %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | 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-field">
<label for="edit-parent-search" data-i18n="modalParents">上级条目</label>
<div id="edit-parent-list" class="secret-list"></div>
<input id="edit-parent-search" type="text" autocomplete="off" data-i18n-ph="parentSearchPlaceholder" placeholder="按名称搜索条目">
<div id="edit-parent-results" class="secret-list" style="margin-top:8px"></div>
</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="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:#8b949e;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?v={{ version }}"></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 — 条目',
navTrash: '回收站',
entriesTitle: '我的条目',
allTab: '全部',
filterNameLabel: '名称',
filterNamePlaceholder: '输入关键字',
filterMetadataLabel: '元数据值',
filterMetadataPlaceholder: '搜索元数据值',
filterTypeLabel: '类型',
filterTypeAll: '全部',
filterSubmit: '筛选',
filterClear: '清空',
emptyEntries: '暂无条目。',
colName: '名称',
colType: '类型',
colNotes: '备注',
colTags: '标签',
colRelations: '关联',
colSecrets: '密文',
colActions: '操作',
relationParentBadge: '上级',
relationChildBadge: '下级',
rowEdit: '编辑条目',
rowDelete: '删除',
modalTitle: '编辑条目信息',
modalName: '名称',
modalType: '类型',
modalFolder: '文件夹',
modalNotes: '备注',
modalTags: '标签(逗号分隔)',
modalUpdated: '更新',
modalMetadata: '元数据JSON 对象)',
modalParents: '上级条目',
modalSecrets: '密文',
modalCancel: '取消',
modalSave: '保存',
mobileLabelName: '名称',
mobileLabelType: '类型',
mobileLabelNotes: '备注',
mobileLabelTags: '标签',
mobileLabelRelations: '关联',
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}」?',
parentSearchPlaceholder: '按名称搜索条目',
parentSearchEmpty: '没有匹配的条目',
removeParent: '移除上级',
},
'zh-TW': {
pageTitle: 'Secrets — 條目',
navTrash: '回收站',
entriesTitle: '我的條目',
allTab: '全部',
filterNameLabel: '名稱',
filterNamePlaceholder: '輸入關鍵字',
filterMetadataLabel: '中繼資料值',
filterMetadataPlaceholder: '搜尋中繼資料值',
filterTypeLabel: '類型',
filterTypeAll: '全部',
filterSubmit: '篩選',
filterClear: '清除',
emptyEntries: '暫無條目。',
colName: '名稱',
colType: '類型',
colNotes: '備註',
colTags: '標籤',
colRelations: '關聯',
colSecrets: '密文',
colActions: '操作',
relationParentBadge: '上級',
relationChildBadge: '下級',
rowEdit: '編輯條目',
rowDelete: '刪除',
modalTitle: '編輯條目資訊',
modalName: '名稱',
modalType: '類型',
modalFolder: '資料夾',
modalNotes: '備註',
modalTags: '標籤(逗號分隔)',
modalUpdated: '更新時間',
modalMetadata: '中繼資料JSON 物件)',
modalParents: '上級條目',
modalSecrets: '密文',
modalCancel: '取消',
modalSave: '儲存',
mobileLabelName: '名稱',
mobileLabelType: '類型',
mobileLabelNotes: '備註',
mobileLabelTags: '標籤',
mobileLabelRelations: '關聯',
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}」?',
parentSearchPlaceholder: '依名稱搜尋條目',
parentSearchEmpty: '沒有符合的條目',
removeParent: '移除上級',
},
en: {
pageTitle: 'Secrets — Entries',
navTrash: 'Trash',
entriesTitle: 'My entries',
allTab: 'All',
filterNameLabel: 'Name',
filterNamePlaceholder: 'Enter keywords',
filterMetadataLabel: 'Metadata value',
filterMetadataPlaceholder: 'Search metadata values',
filterTypeLabel: 'Type',
filterTypeAll: 'All',
filterSubmit: 'Filter',
filterClear: 'Clear',
emptyEntries: 'No entries.',
colName: 'Name',
colType: 'Type',
colNotes: 'Notes',
colTags: 'Tags',
colRelations: 'Relations',
colSecrets: 'Secrets',
colActions: 'Actions',
relationParentBadge: 'Parent',
relationChildBadge: 'Child',
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)',
modalParents: 'Parent entries',
modalSecrets: 'Secrets',
modalCancel: 'Cancel',
modalSave: 'Save',
mobileLabelName: 'Name',
mobileLabelType: 'Type',
mobileLabelNotes: 'Notes',
mobileLabelTags: 'Tags',
mobileLabelRelations: 'Relations',
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}"?',
parentSearchPlaceholder: 'Search entries by name',
parentSearchEmpty: 'No matching entries',
removeParent: 'Remove parent',
}
};
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-relations': 'mobileLabelRelations',
'.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 editParentList = document.getElementById('edit-parent-list');
var editParentSearch = document.getElementById('edit-parent-search');
var editParentResults = document.getElementById('edit-parent-results');
var currentEntryId = null;
var selectedParents = [];
var parentSearchTimer = null;
function renderSelectedParents() {
editParentList.innerHTML = '';
selectedParents.forEach(function (parent) {
var chip = document.createElement('span');
chip.className = 'secret-chip';
var name = document.createElement('span');
name.className = 'secret-name';
name.textContent = parent.name;
var type = document.createElement('span');
type.className = 'secret-type';
type.textContent = parent.entry_type || parent.type || '';
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'btn-icon btn-view-unlink';
remove.textContent = '×';
remove.title = t('removeParent');
remove.addEventListener('click', function () {
selectedParents = selectedParents.filter(function (item) { return item.id !== parent.id; });
renderSelectedParents();
});
chip.appendChild(name);
chip.appendChild(type);
chip.appendChild(remove);
editParentList.appendChild(chip);
});
}
function renderParentSearchResults(options) {
editParentResults.innerHTML = '';
if (!options.length) {
var empty = document.createElement('div');
empty.className = 'view-locked-msg';
empty.textContent = t('parentSearchEmpty');
editParentResults.appendChild(empty);
return;
}
options.forEach(function (option) {
if (selectedParents.some(function (item) { return item.id === String(option.id); })) return;
var button = document.createElement('button');
button.type = 'button';
button.className = 'secret-chip';
button.style.background = '#161b22';
button.style.cursor = 'pointer';
button.innerHTML = '<span class="secret-name"></span><span class="secret-type"></span>';
button.querySelector('.secret-name').textContent = option.name;
button.querySelector('.secret-type').textContent = [option.folder, option.type].filter(Boolean).join(' / ');
button.addEventListener('click', function () {
selectedParents.push({
id: String(option.id),
name: option.name,
folder: option.folder || '',
entry_type: option.type || ''
});
editParentSearch.value = '';
editParentResults.innerHTML = '';
renderSelectedParents();
});
editParentResults.appendChild(button);
});
}
function searchParentEntries() {
var query = editParentSearch.value.trim();
if (!query || !currentEntryId) {
editParentResults.innerHTML = '';
return;
}
fetch('/api/entries/options?q=' + encodeURIComponent(query) + '&exclude_id=' + encodeURIComponent(currentEntryId), {
credentials: 'same-origin'
}).then(function (response) {
return response.json().then(function (body) {
if (!response.ok) throw new Error(body.error || ('HTTP ' + response.status));
return body;
});
}).then(function (options) {
renderParentSearchResults(options || []);
}).catch(function (error) {
showEditErr(error.message || String(error));
});
}
// ── 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;
}
try {
selectedParents = JSON.parse(tr.getAttribute('data-entry-parents') || '[]');
} catch (err) {
selectedParents = [];
}
editParentSearch.value = '';
editParentResults.innerHTML = '';
renderSelectedParents();
editOverlay.hidden = false;
}
function closeEdit() {
editOverlay.hidden = true;
currentEntryId = null;
showEditErr('');
editUpdatedAt.textContent = '';
editUpdatedAt.title = '';
editParentSearch.value = '';
editParentResults.innerHTML = '';
selectedParents = [];
renderSelectedParents();
}
editParentSearch.addEventListener('input', function () {
clearTimeout(parentSearchTimer);
parentSearchTimer = setTimeout(searchParentEntries, 180);
});
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' && !viewOverlay.hidden) closeView();
});
function refreshListAfterSave(entryId, body) {
if (body.parent_ids) {
window.location.reload();
return;
}
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('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,
parent_ids: selectedParents.map(function (parent) { return parent.id; })
};
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('.col-secrets .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');
if (!id) return;
fetch('/api/entries/' + encodeURIComponent(id), { 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 () { refreshListAfterDelete(id); })
.catch(function (e) { alert(e.message || String(e)); });
});
});
applyLang();
})();
</script>
</body>
</html>