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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user