feat: user-scoped history/delete/rollback, dashboard & login UI, ignore *.pem

- Filter history/rollback/delete by user_id in secrets-core
- MCP tools/web pass user context; dashboard refresh; favicon static
- .gitignore *.pem; vscode tasks tweaks
- clippy: collapse else-if in rollback latest-history branch

Made-with: Cursor
This commit is contained in:
voson
2026-03-20 20:11:19 +08:00
parent 49fb7430a8
commit 5df4141935
14 changed files with 536 additions and 165 deletions

View File

@@ -3,6 +3,7 @@
<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; }
@@ -31,9 +32,9 @@
background: none; color: var(--text); font-size: 12px; cursor: pointer; }
.btn-sign-out:hover { background: var(--surface2); }
/* Main */
.main { display: flex; justify-content: center; align-items: flex-start;
padding: 48px 24px; min-height: calc(100vh - 52px); }
/* Main: column so footer can sit at bottom of viewport when content is short */
.main { display: flex; flex-direction: column; align-items: center;
padding: 48px 24px 24px; min-height: calc(100vh - 52px); }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 32px; width: 100%; max-width: 980px; }
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 6px; }
@@ -49,6 +50,18 @@
color: var(--text); padding: 9px 12px; border-radius: 6px;
font-size: 13px; outline: none; }
.field input:focus { border-color: var(--accent); }
.pw-field { position: relative; }
.pw-field > input { padding-right: 42px; }
.pw-toggle {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border: none; border-radius: 6px;
background: transparent; color: var(--text-muted); cursor: pointer;
}
.pw-toggle:hover { color: var(--text); background: var(--surface2); }
.pw-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pw-icon svg { display: block; }
.pw-icon.hidden { display: none; }
.error-msg { color: var(--danger); font-size: 12px; margin-top: 6px; display: none; }
/* Buttons */
@@ -69,11 +82,14 @@
.btn-copy:hover { background: rgba(63,185,80,0.2); }
.btn-copy.copied { background: var(--success); color: #0d1117; border-color: var(--success); }
.support-row { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.support-chip { display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 999px;
border: 1px solid var(--border); background: var(--surface2);
color: var(--text-muted); font-size: 12px; }
/* Config format switcher */
.config-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; }
.config-tab { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
background: var(--surface2); color: var(--text-muted); cursor: pointer;
font-family: inherit; text-align: left; transition: border-color 0.15s, background 0.15s, transform 0.15s; }
.config-tab:hover { color: var(--text); border-color: var(--accent); transform: translateY(-1px); }
.config-tab.active { background: rgba(88,166,255,0.1); color: var(--text); border-color: var(--accent); }
.config-tab-title { display: block; font-size: 13px; font-weight: 600; color: inherit; }
/* Config box */
.config-wrap { position: relative; margin-bottom: 14px; }
.config-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
@@ -111,6 +127,22 @@
.btn-modal-cancel { padding: 8px 16px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 13px; cursor: pointer; }
.btn-modal-cancel:hover { background: var(--surface2); }
@media (max-width: 720px) {
.config-tabs { grid-template-columns: 1fr; }
}
.app-footer {
margin-top: auto;
width: 100%;
max-width: 980px;
flex-shrink: 0;
text-align: center;
padding-top: 28px;
font-size: 12px;
color: #9da7b3;
font-family: 'JetBrains Mono', monospace;
}
</style>
</head>
<body data-has-passphrase="{{ has_passphrase }}" data-base-url="{{ base_url }}">
@@ -140,12 +172,6 @@
<div class="info-title" data-i18n="aboutTitle">说明</div>
<div class="info-line" data-i18n="aboutApiKey">API Key 用于身份认证,告诉服务端“你是谁”。</div>
</div>
<div class="support-row" aria-label="Supported clients">
<span class="support-chip">Cursor</span>
<span class="support-chip">Claude Code</span>
<span class="support-chip">Codex</span>
<span class="support-chip">Gemini CLI</span>
</div>
<!-- placeholder config -->
<div class="config-wrap">
@@ -156,11 +182,25 @@
<div id="setup-form" style="display:none">
<div class="field">
<label data-i18n="labelPassphrase">加密密码</label>
<input type="password" id="setup-pass1" data-i18n-ph="phPassphrase">
<div class="pw-field">
<input type="password" id="setup-pass1" data-i18n-ph="phPassphrase" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="setup-pass1" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="field">
<label data-i18n="labelConfirm">确认密码</label>
<input type="password" id="setup-pass2" data-i18n-ph="phConfirm">
<div class="pw-field">
<input type="password" id="setup-pass2" data-i18n-ph="phConfirm" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="setup-pass2" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="error-msg" id="setup-error"></div>
<button class="btn-primary" id="setup-btn" onclick="doSetup()">
@@ -175,7 +215,14 @@
<div id="unlock-form" style="display:none">
<div class="field">
<label data-i18n="labelPassphrase">加密密码</label>
<input type="password" id="unlock-pass" data-i18n-ph="phPassphrase">
<div class="pw-field">
<input type="password" id="unlock-pass" data-i18n-ph="phPassphrase" autocomplete="current-password">
<button type="button" class="pw-toggle" data-target="unlock-pass" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="error-msg" id="unlock-error"></div>
<button class="btn-primary" id="unlock-btn" onclick="doUnlock()">
@@ -186,41 +233,43 @@
<!-- ── Unlocked state ────────────────────────────────────────────────── -->
<div id="unlocked-view" style="display:none">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<div class="card-title" data-i18n="unlockedTitle">MCP 配置</div>
<span style="font-size:12px;color:var(--success)"><span data-i18n="ready">已就绪</span></span>
</div>
<div class="card-sub" data-i18n="unlockedSub">复制以下配置到 AI 客户端的 mcp.json 文件。</div>
<div class="support-row" aria-label="Supported clients" style="margin-bottom:20px">
<span class="support-chip">Cursor</span>
<span class="support-chip">Claude Code</span>
<span class="support-chip">Codex</span>
<span class="support-chip">Gemini CLI</span>
</div>
<div class="card-title" data-i18n="unlockedTitle" style="margin-bottom:16px">MCP 配置</div>
<div class="config-tabs" role="tablist" aria-label="Config format">
<button type="button" class="config-tab active" role="tab" id="tab-mcp" aria-selected="true"
onclick="setConfigFormat('mcp')">
<span class="config-tab-title" data-i18n="tabMcp">Cursor、Claude Code、Codex、Gemini CLI</span>
</button>
<button type="button" class="config-tab" role="tab" id="tab-opencode" aria-selected="false"
onclick="setConfigFormat('opencode')">
<span class="config-tab-title" data-i18n="tabOpencode">OpenCode</span>
</button>
</div>
<div class="config-wrap">
<pre class="config-box" id="real-config"></pre>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<button class="btn-copy" id="copy-full-btn" onclick="copyFullConfig()" style="flex:1">
<span id="copy-full-text" data-i18n="btnCopyFull">复制全部 mcp.json</span>
<span id="copy-full-text">复制完整 mcp.json</span>
</button>
<button class="btn-copy" id="copy-secrets-btn" onclick="copySecretsConfig()" style="flex:1">
<span id="copy-secrets-text" data-i18n="btnCopySecrets">复制 secrets 配置</span>
<span id="copy-secrets-text">复制 secrets 节点</span>
</button>
</div>
<hr class="divider">
<div class="actions-row">
<button class="btn-sm" onclick="clearAndLock()" data-i18n="btnClear">清除本地加密密钥</button>
<button class="btn-sm" onclick="clearAndLock()" data-i18n="btnClear">清除密钥</button>
<button class="btn-sm" onclick="openChangeModal()" data-i18n="btnChangePass">更换密码</button>
<button class="btn-sm" onclick="confirmRegenerate()" data-i18n="btnRegen">新生成 API Key</button>
<button class="btn-sm" onclick="confirmRegenerate()" data-i18n="btnRegen"> API Key</button>
</div>
</div>
</div>
<footer class="app-footer">{{ version }}</footer>
</div>
<!-- ── Change passphrase modal ──────────────────────────────────────────────── -->
@@ -229,11 +278,25 @@
<h3 data-i18n="changeTitle">更换密码</h3>
<div class="field">
<label data-i18n="labelNew">新密码</label>
<input type="password" id="change-pass1" data-i18n-ph="phPassphrase">
<div class="pw-field">
<input type="password" id="change-pass1" data-i18n-ph="phPassphrase" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="change-pass1" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="field">
<label data-i18n="labelConfirm">确认</label>
<input type="password" id="change-pass2" data-i18n-ph="phConfirm">
<div class="pw-field">
<input type="password" id="change-pass2" data-i18n-ph="phConfirm" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="change-pass2" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="error-msg" id="change-error"></div>
<div class="modal-actions">
@@ -250,9 +313,9 @@ const T = {
'zh-CN': {
signOut: '退出',
lockedTitle: '获取 MCP 配置',
lockedSub: '输入加密密码,派生密钥后生成完整的 MCP 配置,可用于 Cursor、Claude Code、Codex 和 Gemini CLI。',
lockedSub: '输入加密密码,派生密钥后生成 MCP 配置;请按你所用客户端在解锁后选择对应卡片。',
aboutTitle: '说明',
aboutApiKey: 'API Key 用于身份认证,告诉服务端“你是谁”。',
aboutApiKey: 'API Key 用于身份认证X-Encryption-Key 用于加解密密文。二者请仅保存在本机配置中。',
labelPassphrase: '加密密码',
labelConfirm: '确认密码',
labelNew: '新密码',
@@ -266,26 +329,30 @@ const T = {
errMismatch: '两次输入不一致。',
errWrong: '密码错误,请重试。',
unlockedTitle: 'MCP 配置',
unlockedSub: '复制以下 `mcp.json` 风格配置,并按目标客户端需要自行调整字段名。',
ready: '已就绪',
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
tabOpencode: 'OpenCode',
btnCopyFull: '复制完整 mcp.json',
btnCopySecrets: '复制 secrets 条目',
btnCopySecrets: '复制 secrets 节点',
btnCopyFullOpencode: '复制完整 mcp.json',
btnCopySecretsOpencode: '仅复制 secrets 节点',
btnCopied: '已复制!',
btnClear: '清除本地加密密钥',
btnClear: '清除密钥',
btnChangePass: '更换密码',
btnRegen: '重新生成 API Key',
btnRegen: '重 API Key',
changeTitle: '更换密码',
btnChange: '确认更换',
btnCancel: '取消',
regenConfirm: '重新生成 API Key 后,当前 Key 立即失效,需同步更新 AI 客户端配置。确认继续?',
regenFailed: '重新生成失败,请刷新页面重试。',
regenConfirm: '重 API Key 后,当前 Key 立即失效,需同步更新 AI 客户端配置。确认继续?',
regenFailed: '重失败,请刷新页面重试。',
ariaShowPw: '显示密码',
ariaHidePw: '隐藏密码',
},
'zh-TW': {
signOut: '登出',
lockedTitle: '取得 MCP 設定',
lockedSub: '輸入加密密碼,派生金鑰後生成完整的 MCP 設定,可用於 Cursor、Claude Code、Codex 與 Gemini CLI。',
lockedSub: '輸入加密密碼,派生金鑰後生成 MCP 設定;請依你所用用戶端在解鎖後選擇對應卡片。',
aboutTitle: '說明',
aboutApiKey: 'API Key 用於身份驗證,告訴服務端「你是誰」。',
aboutApiKey: 'API Key 用於身份驗證X-Encryption-Key 用於加解密密文。二者請僅保存在本機設定中。',
labelPassphrase: '加密密碼',
labelConfirm: '確認密碼',
labelNew: '新密碼',
@@ -299,26 +366,30 @@ const T = {
errMismatch: '兩次輸入不一致。',
errWrong: '密碼錯誤,請重試。',
unlockedTitle: 'MCP 設定',
unlockedSub: '複製以下 `mcp.json` 風格設定,並依目標用戶端需要自行調整欄位名稱。',
ready: '已就緒',
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
tabOpencode: 'OpenCode',
btnCopyFull: '複製完整 mcp.json',
btnCopySecrets: '複製 secrets 項目',
btnCopySecrets: '複製 secrets 節點',
btnCopyFullOpencode: '複製完整 mcp.json',
btnCopySecretsOpencode: '僅複製 secrets 節點',
btnCopied: '已複製!',
btnClear: '清除本地加密金鑰',
btnClear: '清除鑰',
btnChangePass: '更換密碼',
btnRegen: '重新產生 API Key',
btnRegen: '重 API Key',
changeTitle: '更換密碼',
btnChange: '確認更換',
btnCancel: '取消',
regenConfirm: '重新產生 API Key 後,目前 Key 立即失效,需同步更新 AI 用戶端設定。確認繼續?',
regenFailed: '重新產生失敗,請重新整理頁面再試。',
regenConfirm: '重 API Key 後,目前 Key 立即失效,需同步更新 AI 用戶端設定。確認繼續?',
regenFailed: '重失敗,請重新整理頁面再試。',
ariaShowPw: '顯示密碼',
ariaHidePw: '隱藏密碼',
},
'en': {
signOut: 'Sign out',
lockedTitle: 'Get MCP Config',
lockedSub: 'Enter your encryption password to derive your key and generate an MCP config for Cursor, Claude Code, Codex, and Gemini CLI.',
lockedSub: 'Enter your encryption password to derive your key and generate MCP config. After unlock, pick the card that matches your client.',
aboutTitle: 'About',
aboutApiKey: 'The API key is used for authentication and tells the server who you are.',
aboutApiKey: 'The API key authenticates you; X-Encryption-Key encrypts secret payloads. Keep both only in local client config.',
labelPassphrase: 'Encryption password',
labelConfirm: 'Confirm password',
labelNew: 'New password',
@@ -332,19 +403,23 @@ const T = {
errMismatch: 'Passwords do not match.',
errWrong: 'Incorrect password, please try again.',
unlockedTitle: 'MCP Config',
unlockedSub: 'Copy the `mcp.json`-style config below and adapt field names if your client expects a different schema.',
ready: 'Ready',
tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI',
tabOpencode: 'OpenCode',
btnCopyFull: 'Copy full mcp.json',
btnCopySecrets: 'Copy secrets entry',
btnCopySecrets: 'Copy only secrets node',
btnCopyFullOpencode: 'Copy full mcp.json',
btnCopySecretsOpencode: 'Copy only secrets node',
btnCopied: 'Copied!',
btnClear: 'Clear local encryption key',
btnClear: 'Clear key',
btnChangePass: 'Change password',
btnRegen: 'Regenerate API key',
btnRegen: 'Reset API key',
changeTitle: 'Change password',
btnChange: 'Confirm',
btnCancel: 'Cancel',
regenConfirm: 'Regenerating will immediately invalidate your current API key. You will need to update your AI client config. Continue?',
regenFailed: 'Regeneration failed. Please refresh and try again.',
regenConfirm: 'Resetting will immediately invalidate your current API key. You will need to update your AI client config. Continue?',
regenFailed: 'Reset failed. Please refresh and try again.',
ariaShowPw: 'Show password',
ariaHidePw: 'Hide password',
}
};
@@ -369,6 +444,8 @@ function applyLang() {
renderPlaceholderConfig();
// Rebuild real config if unlocked
if (currentEncKey && currentApiKey) renderRealConfig();
syncPwToggleI18n();
syncConfigFormatUi();
}
function setLang(lang) {
@@ -377,6 +454,27 @@ function setLang(lang) {
applyLang();
}
function syncPwToggleI18n() {
document.querySelectorAll('.pw-toggle').forEach(btn => {
const input = document.getElementById(btn.getAttribute('data-target'));
if (!input) return;
const visible = input.type === 'text';
btn.setAttribute('aria-pressed', visible ? 'true' : 'false');
btn.setAttribute('aria-label', visible ? t('ariaHidePw') : t('ariaShowPw'));
const showIc = btn.querySelector('.pw-icon-show');
const hideIc = btn.querySelector('.pw-icon-hide');
if (showIc) showIc.classList.toggle('hidden', visible);
if (hideIc) hideIc.classList.toggle('hidden', !visible);
});
}
function togglePwVisibility(btn) {
const input = document.getElementById(btn.getAttribute('data-target'));
if (!input) return;
input.type = input.type === 'password' ? 'text' : 'password';
syncPwToggleI18n();
}
// ── Constants ──────────────────────────────────────────────────────────────────
const HAS_PASSPHRASE = document.body.dataset.hasPassphrase === 'true';
@@ -386,6 +484,25 @@ const PBKDF2_ITERATIONS = 600000;
const ENC = new TextEncoder();
let currentEncKey = null;
let currentApiKey = null;
/** @type {'mcp' | 'opencode'} */
let configFormat = 'mcp';
function redirectLoginExpired() {
sessionStorage.removeItem('enc_key');
currentEncKey = null;
currentApiKey = null;
window.location.replace('/');
}
/** Like fetch; on 401 clears local state and navigates to login (await does not complete). */
async function fetchAuth(input, init) {
const resp = await fetch(input, init);
if (resp.status === 401) {
redirectLoginExpired();
await new Promise(() => {});
}
return resp;
}
// ── Placeholder config ─────────────────────────────────────────────────────────
@@ -420,6 +537,68 @@ function buildSecretsConfigText(apiKey, encKey) {
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
}
/** OpenCode: local stdio bridge to Streamable HTTP MCP (mcp-remote --transport http-only). */
function buildOpencodeEntry(apiKey, encKey) {
return {
type: 'local',
command: [
'npx', '-y', 'mcp-remote',
BASE_URL + '/mcp',
'--header',
'Authorization: Bearer ' + apiKey,
'--header',
'X-Encryption-Key: ' + encKey,
'--transport',
'http-only'
]
};
}
function buildOpencodeConfigText(apiKey, encKey) {
return JSON.stringify({ secrets: buildOpencodeEntry(apiKey, encKey) }, null, 2);
}
function buildOpencodeMergeSnippet(apiKey, encKey) {
const wrapped = buildOpencodeConfigText(apiKey, encKey);
const lines = wrapped.split('\n');
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
}
function getCopyFullKey() {
return 'btnCopyFull';
}
function getCopySecretsKey() {
return 'btnCopySecrets';
}
const CONFIG_FORMAT_STORAGE = 'dash_config_format';
function setConfigFormat(fmt) {
configFormat = fmt;
try { sessionStorage.setItem(CONFIG_FORMAT_STORAGE, fmt); } catch (_) {}
syncConfigFormatUi();
if (currentEncKey && currentApiKey) renderRealConfig();
}
/** Refresh tabs, format hint, and copy button labels (after language change or tab switch). */
function syncConfigFormatUi() {
const uv = document.getElementById('unlocked-view');
if (!uv || uv.style.display === 'none') return;
const tabMcp = document.getElementById('tab-mcp');
const tabOc = document.getElementById('tab-opencode');
if (tabMcp && tabOc) {
tabMcp.classList.toggle('active', configFormat === 'mcp');
tabOc.classList.toggle('active', configFormat === 'opencode');
tabMcp.setAttribute('aria-selected', configFormat === 'mcp' ? 'true' : 'false');
tabOc.setAttribute('aria-selected', configFormat === 'opencode' ? 'true' : 'false');
}
const cf = document.getElementById('copy-full-text');
const cs = document.getElementById('copy-secrets-text');
if (cf) cf.textContent = t(getCopyFullKey());
if (cs) cs.textContent = t(getCopySecretsKey());
}
// ── Unlock / Setup flow ───────────────────────────────────────────────────────
function showLockedView() {
@@ -443,11 +622,15 @@ async function showUnlockedView(encKeyHex, apiKey) {
renderRealConfig();
document.getElementById('locked-view').style.display = 'none';
document.getElementById('unlocked-view').style.display = '';
syncConfigFormatUi();
}
function renderRealConfig() {
document.getElementById('real-config').textContent =
buildConfigText(currentApiKey, currentEncKey);
if (!currentApiKey || !currentEncKey) return;
const text = configFormat === 'mcp'
? buildConfigText(currentApiKey, currentEncKey)
: buildOpencodeConfigText(currentApiKey, currentEncKey);
document.getElementById('real-config').textContent = text;
}
function clearAndLock() {
@@ -525,7 +708,7 @@ async function doSetup() {
const keyCheckHex = await encryptKeyCheck(cryptoKey);
const hexKey = await exportKeyHex(cryptoKey);
const resp = await fetch('/api/key-setup', {
const resp = await fetchAuth('/api/key-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -556,7 +739,7 @@ async function doUnlock() {
setBtnLoading('unlock-btn', true, 'btnUnlock');
try {
const saltResp = await fetch('/api/key-salt');
const saltResp = await fetchAuth('/api/key-salt');
if (!saltResp.ok) throw new Error('HTTP ' + saltResp.status);
const saltData = await saltResp.json();
@@ -581,16 +764,19 @@ async function copyFullConfig() {
document.getElementById('real-config').textContent,
'copy-full-btn',
'copy-full-text',
'btnCopyFull'
getCopyFullKey()
);
}
async function copySecretsConfig() {
const snippet = configFormat === 'mcp'
? buildSecretsConfigText(currentApiKey, currentEncKey)
: buildOpencodeMergeSnippet(currentApiKey, currentEncKey);
await copyWithFeedback(
buildSecretsConfigText(currentApiKey, currentEncKey),
snippet,
'copy-secrets-btn',
'copy-secrets-text',
'btnCopySecrets'
getCopySecretsKey()
);
}
@@ -606,12 +792,12 @@ async function copyWithFeedback(text, btnId, textId, resetLabelKey) {
}, 2500);
}
// ── Regenerate API key ─────────────────────────────────────────────────────────
// ── Reset API key ──────────────────────────────────────────────────────────────
async function confirmRegenerate() {
if (!confirm(t('regenConfirm'))) return;
try {
const resp = await fetch('/api/apikey/regenerate', { method: 'POST' });
const resp = await fetchAuth('/api/apikey/regenerate', { method: 'POST' });
if (!resp.ok) throw new Error();
const data = await resp.json();
currentApiKey = data.api_key;
@@ -626,8 +812,11 @@ async function confirmRegenerate() {
function openChangeModal() {
document.getElementById('change-pass1').value = '';
document.getElementById('change-pass2').value = '';
document.getElementById('change-pass1').type = 'password';
document.getElementById('change-pass2').type = 'password';
document.getElementById('change-error').style.display = 'none';
document.getElementById('change-modal').classList.add('open');
syncPwToggleI18n();
setTimeout(() => document.getElementById('change-pass1').focus(), 50);
}
@@ -654,7 +843,7 @@ async function doChange() {
const keyCheckHex = await encryptKeyCheck(cryptoKey);
const hexKey = await exportKeyHex(cryptoKey);
const resp = await fetch('/api/key-setup', {
const resp = await fetchAuth('/api/key-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -680,7 +869,7 @@ async function doChange() {
// ── Fetch API key ──────────────────────────────────────────────────────────────
async function fetchApiKey() {
const resp = await fetch('/api/apikey');
const resp = await fetchAuth('/api/apikey');
if (!resp.ok) throw new Error('Failed to load API key');
const data = await resp.json();
return data.api_key;
@@ -710,6 +899,10 @@ document.addEventListener('keydown', e => {
(async function init() {
applyLang();
try {
const sf = sessionStorage.getItem(CONFIG_FORMAT_STORAGE);
if (sf === 'mcp' || sf === 'opencode') configFormat = sf;
} catch (_) { /* ignore */ }
const savedKey = sessionStorage.getItem('enc_key');
if (savedKey) {
try {

View File

@@ -3,10 +3,11 @@
<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 — Sign In</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');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117;
--surface: #161b22;
@@ -29,9 +30,6 @@
.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); }
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 32px; }
.logo-icon { font-family: 'JetBrains Mono', monospace; font-size: 24px; color: var(--accent); }
.logo-text { font-family: 'JetBrains Mono', monospace; font-size: 18px; font-weight: 600; }
h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 32px; }
.btn {
@@ -56,10 +54,6 @@
<button class="lang-btn" onclick="setLang('en')">EN</button>
</div>
</div>
<div class="logo">
<span class="logo-icon">🔐</span>
<span class="logo-text">secrets</span>
</div>
<h1 data-i18n="title">登录</h1>
<p class="subtitle" data-i18n="subtitle">安全管理你的跨设备 secrets。</p>