1541 lines
64 KiB
HTML
1541 lines
64 KiB
HTML
<!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); }
|
||
</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>
|
||
<button type="button" class="btn-unlink-secret" data-secret-id="{{ s.id }}" data-secret-name="{{ s.name }}" title="解除关联">×</button>
|
||
</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-field modal-secrets"><label data-i18n="modalSecrets">密文</label><div id="edit-secrets-list" class="secret-list"></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="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: '解密中…',
|
||
},
|
||
'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: '解密中…',
|
||
},
|
||
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…',
|
||
}
|
||
};
|
||
|
||
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]));
|
||
});
|
||
});
|
||
editSecretsList.querySelectorAll('.btn-unlink-secret').forEach(function (btn) {
|
||
btn.title = t('unlinkTitle');
|
||
});
|
||
editSecretsList.querySelectorAll('.secret-name-input').forEach(function (input) {
|
||
input.placeholder = t('renameSecretPlaceholder');
|
||
});
|
||
document.querySelectorAll('.table-wrap .btn-unlink-secret').forEach(function (btn) {
|
||
btn.title = t('unlinkTitle');
|
||
});
|
||
};
|
||
|
||
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 editSecretsList = document.getElementById('edit-secrets-list');
|
||
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) {
|
||
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;
|
||
}
|
||
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 row = document.createElement('div');
|
||
row.className = 'view-secret-row';
|
||
|
||
var header = document.createElement('div');
|
||
header.className = 'view-secret-header';
|
||
|
||
var nameSpan = document.createElement('span');
|
||
nameSpan.className = 'view-secret-name';
|
||
nameSpan.textContent = name;
|
||
header.appendChild(nameSpan);
|
||
|
||
var actions = document.createElement('div');
|
||
actions.className = 'view-secret-actions';
|
||
|
||
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);
|
||
|
||
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);
|
||
|
||
viewBody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
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 = '';
|
||
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);
|
||
|
||
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 || {});
|
||
}).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 renderEditSecrets(secrets) {
|
||
editSecretsList.innerHTML = '';
|
||
if (!Array.isArray(secrets) || secrets.length === 0) return;
|
||
secrets.forEach(function (s) {
|
||
var row = document.createElement('div');
|
||
row.className = 'secret-edit-row';
|
||
row.setAttribute('data-secret-id', s.id || '');
|
||
row.setAttribute('data-secret-name', s.name || '');
|
||
row.setAttribute('data-secret-type', s.secret_type || '');
|
||
|
||
var main = document.createElement('div');
|
||
main.className = 'secret-edit-main';
|
||
|
||
var input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'secret-name-input';
|
||
input.value = s.name || '';
|
||
input.placeholder = t('renameSecretPlaceholder');
|
||
input.setAttribute('data-original-name', s.name || '');
|
||
|
||
var typeSelect = document.createElement('select');
|
||
typeSelect.className = 'secret-type-select';
|
||
var currentType = s.secret_type || 'text';
|
||
var hasCurrentInOptions = SECRET_TYPE_OPTIONS.indexOf(currentType) !== -1;
|
||
SECRET_TYPE_OPTIONS.forEach(function (opt) {
|
||
var option = document.createElement('option');
|
||
option.value = opt;
|
||
option.textContent = opt;
|
||
if (opt === currentType) option.selected = true;
|
||
typeSelect.appendChild(option);
|
||
});
|
||
if (!hasCurrentInOptions && currentType) {
|
||
var fallback = document.createElement('option');
|
||
fallback.value = currentType;
|
||
fallback.textContent = currentType;
|
||
fallback.selected = true;
|
||
typeSelect.appendChild(fallback);
|
||
}
|
||
|
||
var unlinkBtn = document.createElement('button');
|
||
unlinkBtn.type = 'button';
|
||
unlinkBtn.className = 'btn-unlink-secret';
|
||
unlinkBtn.setAttribute('data-secret-id', s.id || '');
|
||
unlinkBtn.setAttribute('data-secret-name', s.name || '');
|
||
unlinkBtn.title = t('unlinkTitle');
|
||
unlinkBtn.textContent = '\u00d7';
|
||
|
||
main.appendChild(typeSelect);
|
||
main.appendChild(input);
|
||
main.appendChild(unlinkBtn);
|
||
|
||
var status = document.createElement('div');
|
||
status.className = 'secret-name-status';
|
||
status.setAttribute('data-status', 'idle');
|
||
|
||
row.appendChild(main);
|
||
row.appendChild(status);
|
||
editSecretsList.appendChild(row);
|
||
|
||
bindSecretValidation(row, s.id, s.name, s.secret_type || '');
|
||
});
|
||
}
|
||
|
||
function bindSecretValidation(row, secretId, originalName, originalType) {
|
||
var input = row.querySelector('.secret-name-input');
|
||
var typeSelect = row.querySelector('.secret-type-select');
|
||
var status = row.querySelector('.secret-name-status');
|
||
var debounceTimer = null;
|
||
var currentCheck = null;
|
||
var lastValidatedName = originalName;
|
||
|
||
function setStatus(text, type) {
|
||
status.textContent = text || '';
|
||
status.className = 'secret-name-status';
|
||
if (type) status.classList.add(type);
|
||
row.setAttribute('data-validation-state', type || 'idle');
|
||
}
|
||
|
||
function setLastValidatedName(name) {
|
||
row.setAttribute('data-last-validated-name', name || '');
|
||
}
|
||
|
||
function invalidateValidationState() {
|
||
currentCheck = null;
|
||
lastValidatedName = null;
|
||
setLastValidatedName('');
|
||
setStatus('', 'idle');
|
||
input.className = 'secret-name-input';
|
||
}
|
||
|
||
function checkNameAvailability(name) {
|
||
if (!name || name === originalName) {
|
||
setStatus('', 'idle');
|
||
input.className = 'secret-name-input';
|
||
setLastValidatedName(originalName);
|
||
return Promise.resolve(false);
|
||
}
|
||
|
||
if (name.length > 256) {
|
||
setStatus(t('secretNameInvalid'), 'error');
|
||
input.className = 'secret-name-input invalid';
|
||
setLastValidatedName('');
|
||
return Promise.resolve(false);
|
||
}
|
||
|
||
setStatus(t('checkingSecretName'), 'checking');
|
||
input.className = 'secret-name-input';
|
||
|
||
var checkId = Date.now();
|
||
currentCheck = checkId;
|
||
|
||
var params = new URLSearchParams();
|
||
params.set('name', name);
|
||
params.set('exclude_secret_id', secretId);
|
||
|
||
return fetch('/api/secrets/check-name?' + params.toString(), {
|
||
credentials: 'same-origin'
|
||
}).then(function (r) {
|
||
return r.json();
|
||
}).then(function (data) {
|
||
if (currentCheck !== checkId) return false;
|
||
if (data.ok && data.available) {
|
||
setStatus(t('secretNameAvailable'), 'success');
|
||
input.className = 'secret-name-input valid';
|
||
lastValidatedName = name;
|
||
setLastValidatedName(name);
|
||
return true;
|
||
} else {
|
||
setStatus(data.error || t('secretNameTaken'), 'error');
|
||
input.className = 'secret-name-input invalid';
|
||
setLastValidatedName('');
|
||
return false;
|
||
}
|
||
}).catch(function () {
|
||
if (currentCheck !== checkId) return false;
|
||
setStatus(t('secretNameCheckError'), 'error');
|
||
input.className = 'secret-name-input invalid';
|
||
setLastValidatedName('');
|
||
return false;
|
||
});
|
||
}
|
||
|
||
row.secretValidateNow = function () {
|
||
return checkNameAvailability(input.value.trim());
|
||
};
|
||
|
||
input.addEventListener('input', function () {
|
||
var newName = input.value.trim();
|
||
if (debounceTimer) clearTimeout(debounceTimer);
|
||
invalidateValidationState();
|
||
debounceTimer = setTimeout(function () {
|
||
checkNameAvailability(newName);
|
||
}, 300);
|
||
});
|
||
|
||
input.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
checkNameAvailability(input.value.trim());
|
||
} else if (e.key === 'Escape') {
|
||
input.value = originalName;
|
||
invalidateValidationState();
|
||
}
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
var sj = tr.getAttribute('data-entry-secrets') || '[]';
|
||
try {
|
||
renderEditSecrets(JSON.parse(sj));
|
||
} catch (err) {
|
||
renderEditSecrets([]);
|
||
}
|
||
editOverlay.hidden = false;
|
||
}
|
||
|
||
function closeEdit() {
|
||
editOverlay.hidden = true;
|
||
currentEntryId = null;
|
||
showEditErr('');
|
||
editSecretsList.innerHTML = '';
|
||
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, secretRows) {
|
||
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(', ');
|
||
var secretsList = tr.querySelector('.secret-list');
|
||
if (secretsList) {
|
||
secretsList.innerHTML = '';
|
||
secretRows.forEach(function (info) {
|
||
var chip = document.createElement('span');
|
||
chip.className = 'secret-chip';
|
||
var nameSpan = document.createElement('span');
|
||
nameSpan.className = 'secret-name';
|
||
nameSpan.textContent = info.newName;
|
||
nameSpan.title = info.newName;
|
||
var typeSpan = document.createElement('span');
|
||
typeSpan.className = 'secret-type';
|
||
typeSpan.textContent = info.newType || 'text';
|
||
var unlinkBtn = document.createElement('button');
|
||
unlinkBtn.type = 'button';
|
||
unlinkBtn.className = 'btn-unlink-secret';
|
||
unlinkBtn.setAttribute('data-secret-id', info.secretId);
|
||
unlinkBtn.setAttribute('data-secret-name', info.newName);
|
||
unlinkBtn.title = t('unlinkTitle');
|
||
unlinkBtn.textContent = '\u00d7';
|
||
chip.appendChild(nameSpan);
|
||
chip.appendChild(typeSpan);
|
||
chip.appendChild(unlinkBtn);
|
||
secretsList.appendChild(chip);
|
||
});
|
||
}
|
||
tr.setAttribute('data-entry-folder', body.folder);
|
||
tr.setAttribute('data-entry-metadata', JSON.stringify(body.metadata));
|
||
var updatedSecrets = secretRows.map(function (info) {
|
||
return { id: info.secretId, name: info.newName, secret_type: info.newType || 'text' };
|
||
});
|
||
tr.setAttribute('data-entry-secrets', JSON.stringify(updatedSecrets));
|
||
}
|
||
|
||
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 + ')';
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function refreshListAfterUnlink(entryId, secretId) {
|
||
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
|
||
if (!tr) return;
|
||
var chip = tr.querySelector('.btn-unlink-secret[data-secret-id="' + secretId + '"]');
|
||
if (chip && chip.parentElement) chip.parentElement.remove();
|
||
var secrets = tr.getAttribute('data-entry-secrets');
|
||
try {
|
||
var arr = JSON.parse(secrets);
|
||
arr = arr.filter(function (s) { return s.id !== secretId; });
|
||
tr.setAttribute('data-entry-secrets', JSON.stringify(arr));
|
||
} catch (e) {}
|
||
}
|
||
|
||
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
|
||
};
|
||
var secretRows = Array.from(editSecretsList.querySelectorAll('.secret-edit-row')).map(function (row) {
|
||
var input = row.querySelector('.secret-name-input');
|
||
var typeSelect = row.querySelector('.secret-type-select');
|
||
return {
|
||
row: row,
|
||
input: input,
|
||
typeSelect: typeSelect,
|
||
secretId: row.getAttribute('data-secret-id') || '',
|
||
originalName: input ? (input.getAttribute('data-original-name') || '') : '',
|
||
newName: input ? input.value.trim() : '',
|
||
originalType: row.getAttribute('data-secret-type') || '',
|
||
newType: typeSelect ? typeSelect.value : ''
|
||
};
|
||
});
|
||
showEditErr('');
|
||
Promise.all(secretRows.map(function (info) {
|
||
if (!info.secretId || info.newName === info.originalName) return Promise.resolve(true);
|
||
if (typeof info.row.secretValidateNow === 'function') {
|
||
return info.row.secretValidateNow();
|
||
}
|
||
return Promise.resolve(false);
|
||
})).then(function () {
|
||
var invalidSecret = secretRows.find(function (info) {
|
||
if (!info.secretId || info.newName === info.originalName) return false;
|
||
var state = info.row.getAttribute('data-validation-state') || 'idle';
|
||
var lastValidated = info.row.getAttribute('data-last-validated-name') || '';
|
||
return state !== 'success' || lastValidated !== info.newName;
|
||
});
|
||
if (invalidSecret) {
|
||
if (invalidSecret.input) invalidSecret.input.focus();
|
||
throw new Error(t('secretNameFixBeforeSave'));
|
||
}
|
||
|
||
return 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 () {
|
||
var changedSecrets = secretRows.filter(function (info) {
|
||
return info.secretId && (info.newName !== info.originalName || info.newType !== info.originalType);
|
||
});
|
||
return Promise.all(changedSecrets.map(function (info) {
|
||
var patchBody = {};
|
||
if (info.newName !== info.originalName) patchBody.name = info.newName;
|
||
if (info.newType !== info.originalType) patchBody.type = info.newType;
|
||
return fetch('/api/secrets/' + encodeURIComponent(info.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;
|
||
});
|
||
}).catch(function (err) {
|
||
var msg = err.message || String(err);
|
||
var status = info.row.querySelector('.secret-name-status');
|
||
info.row.setAttribute('data-validation-state', 'error');
|
||
info.row.setAttribute('data-last-validated-name', '');
|
||
if (status) {
|
||
status.textContent = msg;
|
||
status.className = 'secret-name-status error';
|
||
}
|
||
if (info.input) {
|
||
info.input.className = 'secret-name-input invalid';
|
||
}
|
||
if (info.typeSelect) {
|
||
info.typeSelect.className = 'secret-type-select invalid';
|
||
}
|
||
if (info.input) info.input.focus();
|
||
throw new Error(tf('errRenameSecret', { error: msg }));
|
||
});
|
||
}));
|
||
}).then(function () {
|
||
closeEdit();
|
||
refreshListAfterSave(currentEntryId, body, secretRows);
|
||
}).catch(function (e) {
|
||
showEditErr(e.message || String(e));
|
||
});
|
||
});
|
||
|
||
var tableWrap = document.querySelector('.table-wrap');
|
||
if (tableWrap) {
|
||
tableWrap.addEventListener('click', function (e) {
|
||
var btn = e.target.closest('.btn-unlink-secret');
|
||
if (!btn || !tableWrap.contains(btn)) return;
|
||
var tr = btn.closest('tr[data-entry-id]');
|
||
var entryId = tr && tr.getAttribute('data-entry-id');
|
||
var secretId = btn.getAttribute('data-secret-id');
|
||
var secretName = btn.getAttribute('data-secret-name') || '';
|
||
if (!entryId || !secretId) return;
|
||
fetch('/api/entries/' + encodeURIComponent(entryId) + '/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 () {
|
||
refreshListAfterUnlink(entryId, secretId);
|
||
}).catch(function (err) {
|
||
alert(err.message || String(err));
|
||
});
|
||
});
|
||
}
|
||
|
||
editSecretsList.addEventListener('click', function (e) {
|
||
var btn = e.target.closest('.btn-unlink-secret');
|
||
if (!btn || !editSecretsList.contains(btn)) return;
|
||
var entryId = currentEntryId;
|
||
var secretId = btn.getAttribute('data-secret-id');
|
||
var secretName = btn.getAttribute('data-secret-name') || '';
|
||
if (!entryId || !secretId) return;
|
||
fetch('/api/entries/' + encodeURIComponent(entryId) + '/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 () {
|
||
btn.closest('.secret-edit-row').remove();
|
||
var tableRow = document.querySelector('tr[data-entry-id="' + entryId + '"]');
|
||
if (tableRow) {
|
||
var chip = tableRow.querySelector('.btn-unlink-secret[data-secret-id="' + secretId + '"]');
|
||
if (chip && chip.parentElement) chip.parentElement.remove();
|
||
}
|
||
}).catch(function (err) {
|
||
alert(err.message || String(err));
|
||
});
|
||
});
|
||
|
||
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>
|