- 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
919 lines
45 KiB
HTML
919 lines
45 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;
|
||
--danger: #f85149; --success: #3fb950; --warn: #d29922;
|
||
}
|
||
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||
|
||
/* Nav */
|
||
.nav { background: var(--surface); border-bottom: 1px solid var(--border);
|
||
padding: 0 24px; display: flex; align-items: center; gap: 12px; height: 52px; }
|
||
.nav-logo { font-family: 'JetBrains Mono', monospace; font-size: 15px; font-weight: 600;
|
||
color: var(--text); text-decoration: none; }
|
||
.nav-logo span { color: var(--accent); }
|
||
.nav-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; cursor: pointer; }
|
||
.btn-sign-out:hover { background: var(--surface2); }
|
||
|
||
/* 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; }
|
||
.card-sub { font-size: 13px; color: var(--text-muted); line-height: 1.6; margin-bottom: 24px; }
|
||
.info-box { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
|
||
padding: 12px 14px; margin-bottom: 18px; }
|
||
.info-title { font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 8px; }
|
||
.info-line { font-size: 12px; color: var(--text-muted); line-height: 1.6; }
|
||
/* Form */
|
||
.field { margin-bottom: 12px; }
|
||
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
|
||
.field input { width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||
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 */
|
||
.btn-primary { display: inline-flex; align-items: center; gap: 6px; width: 100%;
|
||
justify-content: center; padding: 10px 20px; border-radius: 7px;
|
||
border: none; background: var(--accent); color: #0d1117;
|
||
font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
||
.btn-primary:hover { background: var(--accent-hover); }
|
||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px;
|
||
border-radius: 5px; border: 1px solid var(--border); background: none;
|
||
color: var(--text-muted); font-size: 12px; cursor: pointer; }
|
||
.btn-sm:hover { color: var(--text); border-color: var(--text-muted); }
|
||
.btn-copy { display: flex; align-items: center; gap: 8px; width: 100%; justify-content: center;
|
||
padding: 11px 20px; border-radius: 7px; border: 1px solid var(--success);
|
||
background: rgba(63,185,80,0.1); color: var(--success);
|
||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
|
||
.btn-copy:hover { background: rgba(63,185,80,0.2); }
|
||
.btn-copy.copied { background: var(--success); color: #0d1117; border-color: var(--success); }
|
||
|
||
/* 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;
|
||
padding: 16px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||
line-height: 1.7; color: var(--text); overflow-x: auto; white-space: pre; }
|
||
.config-box.locked { color: var(--text-muted); filter: blur(3px); user-select: none;
|
||
pointer-events: none; }
|
||
.config-key { color: #79c0ff; }
|
||
.config-str { color: #a5d6ff; }
|
||
.config-val { color: var(--accent); }
|
||
|
||
/* Divider */
|
||
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
||
|
||
/* Actions row */
|
||
.actions-row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||
|
||
/* Spinner */
|
||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(13,17,23,0.3);
|
||
border-top-color: #0d1117; border-radius: 50%; animation: spin 0.7s linear infinite; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* Modal */
|
||
.modal-bd { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75);
|
||
z-index: 100; align-items: center; justify-content: center; }
|
||
.modal-bd.open { display: flex; }
|
||
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||
padding: 28px; width: 100%; max-width: 420px; }
|
||
.modal h3 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
|
||
.modal-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 6px; border: none;
|
||
background: var(--accent); color: #0d1117; font-size: 13px;
|
||
font-weight: 600; cursor: pointer; }
|
||
.btn-modal-ok:hover { background: var(--accent-hover); }
|
||
.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 }}">
|
||
|
||
<nav class="nav">
|
||
<a href="/dashboard" class="nav-logo"><span>secrets</span></a>
|
||
<span class="nav-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>
|
||
</nav>
|
||
|
||
<div class="main">
|
||
<div class="card">
|
||
|
||
<!-- ── Locked state ──────────────────────────────────────────────────── -->
|
||
<div id="locked-view">
|
||
<div class="card-title" data-i18n="lockedTitle">获取 MCP 配置</div>
|
||
<div class="card-sub" data-i18n="lockedSub">输入加密密码,派生密钥后生成完整的 MCP 配置,可直接复制到 AI 客户端。</div>
|
||
<div class="info-box">
|
||
<div class="info-title" data-i18n="aboutTitle">说明</div>
|
||
<div class="info-line" data-i18n="aboutApiKey">API Key 用于身份认证,告诉服务端“你是谁”。</div>
|
||
</div>
|
||
|
||
<!-- placeholder config -->
|
||
<div class="config-wrap">
|
||
<div class="config-box locked" id="placeholder-config"></div>
|
||
</div>
|
||
|
||
<!-- Setup form (no passphrase yet) -->
|
||
<div id="setup-form" style="display:none">
|
||
<div class="field">
|
||
<label data-i18n="labelPassphrase">加密密码</label>
|
||
<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>
|
||
<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()">
|
||
<span data-i18n="btnSetup">设置并获取配置</span>
|
||
</button>
|
||
<p style="font-size:11px;color:var(--text-muted);text-align:center;margin-top:10px" data-i18n="setupNote">
|
||
密码不会上传服务器。遗忘后数据将无法恢复。
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Unlock form (passphrase already set) -->
|
||
<div id="unlock-form" style="display:none">
|
||
<div class="field">
|
||
<label data-i18n="labelPassphrase">加密密码</label>
|
||
<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()">
|
||
<span data-i18n="btnUnlock">解锁并获取配置</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Unlocked state ────────────────────────────────────────────────── -->
|
||
<div id="unlocked-view" style="display:none">
|
||
<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">复制完整 mcp.json</span>
|
||
</button>
|
||
<button class="btn-copy" id="copy-secrets-btn" onclick="copySecretsConfig()" style="flex:1">
|
||
<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="openChangeModal()" data-i18n="btnChangePass">更换密码</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 ──────────────────────────────────────────────── -->
|
||
<div class="modal-bd" id="change-modal">
|
||
<div class="modal">
|
||
<h3 data-i18n="changeTitle">更换密码</h3>
|
||
<div class="field">
|
||
<label data-i18n="labelNew">新密码</label>
|
||
<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>
|
||
<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">
|
||
<button class="btn-modal-ok" id="change-btn" onclick="doChange()" data-i18n="btnChange">确认更换</button>
|
||
<button class="btn-modal-cancel" onclick="closeChangeModal()" data-i18n="btnCancel">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── i18n ───────────────────────────────────────────────────────────────────────
|
||
|
||
const T = {
|
||
'zh-CN': {
|
||
signOut: '退出',
|
||
lockedTitle: '获取 MCP 配置',
|
||
lockedSub: '输入加密密码,派生密钥后生成 MCP 配置;请按你所用客户端在解锁后选择对应卡片。',
|
||
aboutTitle: '说明',
|
||
aboutApiKey: 'API Key 用于身份认证;X-Encryption-Key 用于加解密密文。二者请仅保存在本机配置中。',
|
||
labelPassphrase: '加密密码',
|
||
labelConfirm: '确认密码',
|
||
labelNew: '新密码',
|
||
phPassphrase: '输入密码…',
|
||
phConfirm: '再次输入…',
|
||
btnSetup: '设置并获取配置',
|
||
btnUnlock: '解锁并获取配置',
|
||
setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。',
|
||
errEmpty: '密码不能为空。',
|
||
errShort: '密码至少需要 8 个字符。',
|
||
errMismatch: '两次输入不一致。',
|
||
errWrong: '密码错误,请重试。',
|
||
unlockedTitle: 'MCP 配置',
|
||
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
|
||
tabOpencode: 'OpenCode',
|
||
btnCopyFull: '复制完整 mcp.json',
|
||
btnCopySecrets: '仅复制 secrets 节点',
|
||
btnCopyFullOpencode: '复制完整 mcp.json',
|
||
btnCopySecretsOpencode: '仅复制 secrets 节点',
|
||
btnCopied: '已复制!',
|
||
btnClear: '清除密钥',
|
||
btnChangePass: '更换密码',
|
||
btnRegen: '重置 API Key',
|
||
changeTitle: '更换密码',
|
||
btnChange: '确认更换',
|
||
btnCancel: '取消',
|
||
regenConfirm: '重置 API Key 后,当前 Key 立即失效,需同步更新 AI 客户端配置。确认继续?',
|
||
regenFailed: '重置失败,请刷新页面重试。',
|
||
ariaShowPw: '显示密码',
|
||
ariaHidePw: '隐藏密码',
|
||
},
|
||
'zh-TW': {
|
||
signOut: '登出',
|
||
lockedTitle: '取得 MCP 設定',
|
||
lockedSub: '輸入加密密碼,派生金鑰後生成 MCP 設定;請依你所用用戶端在解鎖後選擇對應卡片。',
|
||
aboutTitle: '說明',
|
||
aboutApiKey: 'API Key 用於身份驗證;X-Encryption-Key 用於加解密密文。二者請僅保存在本機設定中。',
|
||
labelPassphrase: '加密密碼',
|
||
labelConfirm: '確認密碼',
|
||
labelNew: '新密碼',
|
||
phPassphrase: '輸入密碼…',
|
||
phConfirm: '再次輸入…',
|
||
btnSetup: '設定並取得設定',
|
||
btnUnlock: '解鎖並取得設定',
|
||
setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。',
|
||
errEmpty: '密碼不能為空。',
|
||
errShort: '密碼至少需要 8 個字元。',
|
||
errMismatch: '兩次輸入不一致。',
|
||
errWrong: '密碼錯誤,請重試。',
|
||
unlockedTitle: 'MCP 設定',
|
||
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
|
||
tabOpencode: 'OpenCode',
|
||
btnCopyFull: '複製完整 mcp.json',
|
||
btnCopySecrets: '僅複製 secrets 節點',
|
||
btnCopyFullOpencode: '複製完整 mcp.json',
|
||
btnCopySecretsOpencode: '僅複製 secrets 節點',
|
||
btnCopied: '已複製!',
|
||
btnClear: '清除密鑰',
|
||
btnChangePass: '更換密碼',
|
||
btnRegen: '重置 API Key',
|
||
changeTitle: '更換密碼',
|
||
btnChange: '確認更換',
|
||
btnCancel: '取消',
|
||
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 MCP config. After unlock, pick the card that matches your client.',
|
||
aboutTitle: 'About',
|
||
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',
|
||
phPassphrase: 'Enter password…',
|
||
phConfirm: 'Repeat password…',
|
||
btnSetup: 'Set up & get config',
|
||
btnUnlock: 'Unlock & get config',
|
||
setupNote: 'Your password never leaves this device. If forgotten, encrypted data cannot be recovered.',
|
||
errEmpty: 'Password cannot be empty.',
|
||
errShort: 'Password must be at least 8 characters.',
|
||
errMismatch: 'Passwords do not match.',
|
||
errWrong: 'Incorrect password, please try again.',
|
||
unlockedTitle: 'MCP Config',
|
||
tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI',
|
||
tabOpencode: 'OpenCode',
|
||
btnCopyFull: 'Copy full mcp.json',
|
||
btnCopySecrets: 'Copy only secrets node',
|
||
btnCopyFullOpencode: 'Copy full mcp.json',
|
||
btnCopySecretsOpencode: 'Copy only secrets node',
|
||
btnCopied: 'Copied!',
|
||
btnClear: 'Clear key',
|
||
btnChangePass: 'Change password',
|
||
btnRegen: 'Reset API key',
|
||
changeTitle: 'Change password',
|
||
btnChange: 'Confirm',
|
||
btnCancel: 'Cancel',
|
||
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',
|
||
}
|
||
};
|
||
|
||
let currentLang = localStorage.getItem('lang') || 'zh-CN';
|
||
|
||
function t(key) { return T[currentLang][key] || T['en'][key] || key; }
|
||
|
||
function applyLang() {
|
||
document.documentElement.lang = currentLang;
|
||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n');
|
||
el.textContent = t(key);
|
||
});
|
||
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
||
el.placeholder = t(el.getAttribute('data-i18n-ph'));
|
||
});
|
||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
|
||
btn.classList.toggle('active', btn.textContent === map[currentLang]);
|
||
});
|
||
// Rebuild placeholder config (language affects nothing but triggers re-render)
|
||
renderPlaceholderConfig();
|
||
// Rebuild real config if unlocked
|
||
if (currentEncKey && currentApiKey) renderRealConfig();
|
||
syncPwToggleI18n();
|
||
syncConfigFormatUi();
|
||
}
|
||
|
||
function setLang(lang) {
|
||
currentLang = lang;
|
||
localStorage.setItem('lang', 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';
|
||
const BASE_URL = document.body.dataset.baseUrl;
|
||
const KEY_CHECK_PLAINTEXT = 'secrets-mcp-key-check';
|
||
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 ─────────────────────────────────────────────────────────
|
||
|
||
function renderPlaceholderConfig() {
|
||
document.getElementById('placeholder-config').textContent =
|
||
buildConfigText('sk_' + '•'.repeat(64), '•'.repeat(64));
|
||
}
|
||
|
||
function buildBaseServerConfig(apiKey, encKey) {
|
||
return {
|
||
url: BASE_URL + '/mcp',
|
||
headers: {
|
||
Authorization: 'Bearer ' + apiKey,
|
||
'X-Encryption-Key': encKey
|
||
}
|
||
};
|
||
}
|
||
|
||
function buildSecretsEntryObject(apiKey, encKey) {
|
||
return buildBaseServerConfig(apiKey, encKey);
|
||
}
|
||
|
||
function buildConfigText(apiKey, encKey) {
|
||
return JSON.stringify({ mcpServers: { secrets: buildSecretsEntryObject(apiKey, encKey) } }, null, 2);
|
||
}
|
||
|
||
function buildSecretsConfigText(apiKey, encKey) {
|
||
const wrapped = JSON.stringify({
|
||
secrets: buildSecretsEntryObject(apiKey, encKey)
|
||
}, null, 2);
|
||
const lines = wrapped.split('\n');
|
||
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() {
|
||
document.getElementById('locked-view').style.display = '';
|
||
document.getElementById('unlocked-view').style.display = 'none';
|
||
if (HAS_PASSPHRASE) {
|
||
document.getElementById('setup-form').style.display = 'none';
|
||
document.getElementById('unlock-form').style.display = '';
|
||
setTimeout(() => document.getElementById('unlock-pass').focus(), 50);
|
||
} else {
|
||
document.getElementById('setup-form').style.display = '';
|
||
document.getElementById('unlock-form').style.display = 'none';
|
||
setTimeout(() => document.getElementById('setup-pass1').focus(), 50);
|
||
}
|
||
}
|
||
|
||
async function showUnlockedView(encKeyHex, apiKey) {
|
||
currentEncKey = encKeyHex;
|
||
currentApiKey = apiKey;
|
||
sessionStorage.setItem('enc_key', encKeyHex);
|
||
renderRealConfig();
|
||
document.getElementById('locked-view').style.display = 'none';
|
||
document.getElementById('unlocked-view').style.display = '';
|
||
syncConfigFormatUi();
|
||
}
|
||
|
||
function renderRealConfig() {
|
||
if (!currentApiKey || !currentEncKey) return;
|
||
const text = configFormat === 'mcp'
|
||
? buildConfigText(currentApiKey, currentEncKey)
|
||
: buildOpencodeConfigText(currentApiKey, currentEncKey);
|
||
document.getElementById('real-config').textContent = text;
|
||
}
|
||
|
||
function clearAndLock() {
|
||
sessionStorage.removeItem('enc_key');
|
||
currentEncKey = null;
|
||
currentApiKey = null;
|
||
showLockedView();
|
||
}
|
||
|
||
// ── Web Crypto helpers ─────────────────────────────────────────────────────────
|
||
|
||
async function deriveKey(passphrase, saltBytes, extractable = false) {
|
||
const km = await crypto.subtle.importKey('raw', ENC.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
|
||
return crypto.subtle.deriveKey(
|
||
{ name: 'PBKDF2', salt: saltBytes, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||
km, { name: 'AES-GCM', length: 256 }, extractable, ['encrypt', 'decrypt']
|
||
);
|
||
}
|
||
|
||
async function exportKeyHex(cryptoKey) {
|
||
const raw = await crypto.subtle.exportKey('raw', cryptoKey);
|
||
return Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
function hexToBytes(hex) {
|
||
const b = new Uint8Array(hex.length / 2);
|
||
for (let i = 0; i < hex.length; i += 2) b[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||
return b;
|
||
}
|
||
|
||
function bytesToHex(bytes) {
|
||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
async function encryptKeyCheck(cryptoKey) {
|
||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, cryptoKey, ENC.encode(KEY_CHECK_PLAINTEXT));
|
||
const out = new Uint8Array(12 + ct.byteLength);
|
||
out.set(nonce); out.set(new Uint8Array(ct), 12);
|
||
return bytesToHex(out);
|
||
}
|
||
|
||
async function verifyKeyCheck(cryptoKey, keyCheckHex) {
|
||
try {
|
||
const b = hexToBytes(keyCheckHex);
|
||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.slice(0, 12) }, cryptoKey, b.slice(12));
|
||
return new TextDecoder().decode(plain) === KEY_CHECK_PLAINTEXT;
|
||
} catch { return false; }
|
||
}
|
||
|
||
// ── Passphrase setup (first time) ─────────────────────────────────────────────
|
||
|
||
function setBtnLoading(id, loading, labelKey) {
|
||
const btn = document.getElementById(id);
|
||
btn.disabled = loading;
|
||
btn.innerHTML = loading
|
||
? '<span class="spinner"></span>'
|
||
: `<span data-i18n="${labelKey}">${t(labelKey)}</span>`;
|
||
}
|
||
|
||
async function doSetup() {
|
||
const pass1 = document.getElementById('setup-pass1').value;
|
||
const pass2 = document.getElementById('setup-pass2').value;
|
||
const errEl = document.getElementById('setup-error');
|
||
errEl.style.display = 'none';
|
||
|
||
if (!pass1) { showErr(errEl, t('errEmpty')); return; }
|
||
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
|
||
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
|
||
|
||
setBtnLoading('setup-btn', true, 'btnSetup');
|
||
try {
|
||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||
const cryptoKey = await deriveKey(pass1, salt, true);
|
||
const keyCheckHex = await encryptKeyCheck(cryptoKey);
|
||
const hexKey = await exportKeyHex(cryptoKey);
|
||
|
||
const resp = await fetchAuth('/api/key-setup', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
salt: bytesToHex(salt),
|
||
key_check: keyCheckHex,
|
||
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
|
||
})
|
||
});
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
|
||
const apiKey = await fetchApiKey();
|
||
await showUnlockedView(hexKey, apiKey);
|
||
} catch (e) {
|
||
showErr(errEl, 'Error: ' + e.message);
|
||
} finally {
|
||
setBtnLoading('setup-btn', false, 'btnSetup');
|
||
}
|
||
}
|
||
|
||
// ── Passphrase unlock ──────────────────────────────────────────────────────────
|
||
|
||
async function doUnlock() {
|
||
const pass = document.getElementById('unlock-pass').value;
|
||
const errEl = document.getElementById('unlock-error');
|
||
errEl.style.display = 'none';
|
||
|
||
if (!pass) { showErr(errEl, t('errEmpty')); return; }
|
||
|
||
setBtnLoading('unlock-btn', true, 'btnUnlock');
|
||
try {
|
||
const saltResp = await fetchAuth('/api/key-salt');
|
||
if (!saltResp.ok) throw new Error('HTTP ' + saltResp.status);
|
||
const saltData = await saltResp.json();
|
||
|
||
const cryptoKey = await deriveKey(pass, hexToBytes(saltData.salt), true);
|
||
const valid = await verifyKeyCheck(cryptoKey, saltData.key_check);
|
||
if (!valid) { showErr(errEl, t('errWrong')); return; }
|
||
|
||
const hexKey = await exportKeyHex(cryptoKey);
|
||
const apiKey = await fetchApiKey();
|
||
await showUnlockedView(hexKey, apiKey);
|
||
} catch (e) {
|
||
showErr(errEl, 'Error: ' + e.message);
|
||
} finally {
|
||
setBtnLoading('unlock-btn', false, 'btnUnlock');
|
||
}
|
||
}
|
||
|
||
// ── Copy config ────────────────────────────────────────────────────────────────
|
||
|
||
async function copyFullConfig() {
|
||
await copyWithFeedback(
|
||
document.getElementById('real-config').textContent,
|
||
'copy-full-btn',
|
||
'copy-full-text',
|
||
getCopyFullKey()
|
||
);
|
||
}
|
||
|
||
async function copySecretsConfig() {
|
||
const snippet = configFormat === 'mcp'
|
||
? buildSecretsConfigText(currentApiKey, currentEncKey)
|
||
: buildOpencodeMergeSnippet(currentApiKey, currentEncKey);
|
||
await copyWithFeedback(
|
||
snippet,
|
||
'copy-secrets-btn',
|
||
'copy-secrets-text',
|
||
getCopySecretsKey()
|
||
);
|
||
}
|
||
|
||
async function copyWithFeedback(text, btnId, textId, resetLabelKey) {
|
||
await navigator.clipboard.writeText(text);
|
||
const btn = document.getElementById(btnId);
|
||
const textEl = document.getElementById(textId);
|
||
btn.classList.add('copied');
|
||
textEl.textContent = t('btnCopied');
|
||
setTimeout(() => {
|
||
btn.classList.remove('copied');
|
||
textEl.textContent = t(resetLabelKey);
|
||
}, 2500);
|
||
}
|
||
|
||
// ── Reset API key ──────────────────────────────────────────────────────────────
|
||
|
||
async function confirmRegenerate() {
|
||
if (!confirm(t('regenConfirm'))) return;
|
||
try {
|
||
const resp = await fetchAuth('/api/apikey/regenerate', { method: 'POST' });
|
||
if (!resp.ok) throw new Error();
|
||
const data = await resp.json();
|
||
currentApiKey = data.api_key;
|
||
renderRealConfig();
|
||
} catch {
|
||
alert(t('regenFailed'));
|
||
}
|
||
}
|
||
|
||
// ── Change passphrase modal ────────────────────────────────────────────────────
|
||
|
||
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);
|
||
}
|
||
|
||
function closeChangeModal() {
|
||
document.getElementById('change-modal').classList.remove('open');
|
||
}
|
||
|
||
async function doChange() {
|
||
const pass1 = document.getElementById('change-pass1').value;
|
||
const pass2 = document.getElementById('change-pass2').value;
|
||
const errEl = document.getElementById('change-error');
|
||
errEl.style.display = 'none';
|
||
|
||
if (!pass1) { showErr(errEl, t('errEmpty')); return; }
|
||
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
|
||
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
|
||
|
||
const btn = document.getElementById('change-btn');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner" style="border-top-color:#0d1117"></span>';
|
||
try {
|
||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||
const cryptoKey = await deriveKey(pass1, salt, true);
|
||
const keyCheckHex = await encryptKeyCheck(cryptoKey);
|
||
const hexKey = await exportKeyHex(cryptoKey);
|
||
|
||
const resp = await fetchAuth('/api/key-setup', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
salt: bytesToHex(salt),
|
||
key_check: keyCheckHex,
|
||
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
|
||
})
|
||
});
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
|
||
currentEncKey = hexKey;
|
||
sessionStorage.setItem('enc_key', hexKey);
|
||
renderRealConfig();
|
||
closeChangeModal();
|
||
} catch (e) {
|
||
showErr(errEl, 'Error: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('btnChange');
|
||
}
|
||
}
|
||
|
||
// ── Fetch API key ──────────────────────────────────────────────────────────────
|
||
|
||
async function fetchApiKey() {
|
||
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;
|
||
}
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||
|
||
function showErr(el, msg) {
|
||
el.textContent = msg;
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') closeChangeModal();
|
||
if (e.key === 'Enter') {
|
||
if (document.getElementById('change-modal').classList.contains('open')) { doChange(); return; }
|
||
if (document.getElementById('unlock-form').style.display !== 'none' &&
|
||
document.getElementById('locked-view').style.display !== 'none') { doUnlock(); return; }
|
||
if (document.getElementById('setup-form').style.display !== 'none' &&
|
||
document.getElementById('locked-view').style.display !== 'none') { doSetup(); return; }
|
||
}
|
||
});
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────────
|
||
|
||
(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 {
|
||
const apiKey = await fetchApiKey();
|
||
await showUnlockedView(savedKey, apiKey);
|
||
return;
|
||
} catch { /* fall through to locked */ }
|
||
}
|
||
showLockedView();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|