- Split library (db/crypto/service) and MCP/Web/OAuth binary - Add deploy examples and CI/docs updates Made-with: Cursor
726 lines
33 KiB
HTML
726 lines
33 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<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 */
|
|
.main { display: flex; justify-content: center; align-items: flex-start;
|
|
padding: 48px 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); }
|
|
.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); }
|
|
|
|
.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 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); }
|
|
</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>
|
|
<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">
|
|
<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>
|
|
<input type="password" id="setup-pass1" data-i18n-ph="phPassphrase">
|
|
</div>
|
|
<div class="field">
|
|
<label data-i18n="labelConfirm">确认密码</label>
|
|
<input type="password" id="setup-pass2" data-i18n-ph="phConfirm">
|
|
</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>
|
|
<input type="password" id="unlock-pass" data-i18n-ph="phPassphrase">
|
|
</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 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="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>
|
|
</button>
|
|
<button class="btn-copy" id="copy-secrets-btn" onclick="copySecretsConfig()" style="flex:1">
|
|
<span id="copy-secrets-text" data-i18n="btnCopySecrets">复制 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>
|
|
</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>
|
|
<input type="password" id="change-pass1" data-i18n-ph="phPassphrase">
|
|
</div>
|
|
<div class="field">
|
|
<label data-i18n="labelConfirm">确认</label>
|
|
<input type="password" id="change-pass2" data-i18n-ph="phConfirm">
|
|
</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 配置,可用于 Cursor、Claude Code、Codex 和 Gemini CLI。',
|
|
aboutTitle: '说明',
|
|
aboutApiKey: 'API Key 用于身份认证,告诉服务端“你是谁”。',
|
|
labelPassphrase: '加密密码',
|
|
labelConfirm: '确认密码',
|
|
labelNew: '新密码',
|
|
phPassphrase: '输入密码…',
|
|
phConfirm: '再次输入…',
|
|
btnSetup: '设置并获取配置',
|
|
btnUnlock: '解锁并获取配置',
|
|
setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。',
|
|
errEmpty: '密码不能为空。',
|
|
errShort: '密码至少需要 8 个字符。',
|
|
errMismatch: '两次输入不一致。',
|
|
errWrong: '密码错误,请重试。',
|
|
unlockedTitle: 'MCP 配置',
|
|
unlockedSub: '复制以下 `mcp.json` 风格配置,并按目标客户端需要自行调整字段名。',
|
|
ready: '已就绪',
|
|
btnCopyFull: '复制完整 mcp.json',
|
|
btnCopySecrets: '复制 secrets 条目',
|
|
btnCopied: '已复制!',
|
|
btnClear: '清除本地加密密钥',
|
|
btnChangePass: '更换密码',
|
|
btnRegen: '重新生成 API Key',
|
|
changeTitle: '更换密码',
|
|
btnChange: '确认更换',
|
|
btnCancel: '取消',
|
|
regenConfirm: '重新生成 API Key 后,当前 Key 立即失效,需同步更新 AI 客户端配置。确认继续?',
|
|
regenFailed: '重新生成失败,请刷新页面重试。',
|
|
},
|
|
'zh-TW': {
|
|
signOut: '登出',
|
|
lockedTitle: '取得 MCP 設定',
|
|
lockedSub: '輸入加密密碼,派生金鑰後生成完整的 MCP 設定,可用於 Cursor、Claude Code、Codex 與 Gemini CLI。',
|
|
aboutTitle: '說明',
|
|
aboutApiKey: 'API Key 用於身份驗證,告訴服務端「你是誰」。',
|
|
labelPassphrase: '加密密碼',
|
|
labelConfirm: '確認密碼',
|
|
labelNew: '新密碼',
|
|
phPassphrase: '輸入密碼…',
|
|
phConfirm: '再次輸入…',
|
|
btnSetup: '設定並取得設定',
|
|
btnUnlock: '解鎖並取得設定',
|
|
setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。',
|
|
errEmpty: '密碼不能為空。',
|
|
errShort: '密碼至少需要 8 個字元。',
|
|
errMismatch: '兩次輸入不一致。',
|
|
errWrong: '密碼錯誤,請重試。',
|
|
unlockedTitle: 'MCP 設定',
|
|
unlockedSub: '複製以下 `mcp.json` 風格設定,並依目標用戶端需要自行調整欄位名稱。',
|
|
ready: '已就緒',
|
|
btnCopyFull: '複製完整 mcp.json',
|
|
btnCopySecrets: '複製 secrets 項目',
|
|
btnCopied: '已複製!',
|
|
btnClear: '清除本地加密金鑰',
|
|
btnChangePass: '更換密碼',
|
|
btnRegen: '重新產生 API Key',
|
|
changeTitle: '更換密碼',
|
|
btnChange: '確認更換',
|
|
btnCancel: '取消',
|
|
regenConfirm: '重新產生 API Key 後,目前 Key 立即失效,需同步更新 AI 用戶端設定。確認繼續?',
|
|
regenFailed: '重新產生失敗,請重新整理頁面再試。',
|
|
},
|
|
'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.',
|
|
aboutTitle: 'About',
|
|
aboutApiKey: 'The API key is used for authentication and tells the server who you are.',
|
|
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',
|
|
unlockedSub: 'Copy the `mcp.json`-style config below and adapt field names if your client expects a different schema.',
|
|
ready: 'Ready',
|
|
btnCopyFull: 'Copy full mcp.json',
|
|
btnCopySecrets: 'Copy secrets entry',
|
|
btnCopied: 'Copied!',
|
|
btnClear: 'Clear local encryption key',
|
|
btnChangePass: 'Change password',
|
|
btnRegen: 'Regenerate 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.',
|
|
}
|
|
};
|
|
|
|
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();
|
|
}
|
|
|
|
function setLang(lang) {
|
|
currentLang = lang;
|
|
localStorage.setItem('lang', lang);
|
|
applyLang();
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
// ── 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');
|
|
}
|
|
|
|
// ── 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 = '';
|
|
}
|
|
|
|
function renderRealConfig() {
|
|
document.getElementById('real-config').textContent =
|
|
buildConfigText(currentApiKey, currentEncKey);
|
|
}
|
|
|
|
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 fetch('/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 fetch('/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',
|
|
'btnCopyFull'
|
|
);
|
|
}
|
|
|
|
async function copySecretsConfig() {
|
|
await copyWithFeedback(
|
|
buildSecretsConfigText(currentApiKey, currentEncKey),
|
|
'copy-secrets-btn',
|
|
'copy-secrets-text',
|
|
'btnCopySecrets'
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ── Regenerate API key ─────────────────────────────────────────────────────────
|
|
|
|
async function confirmRegenerate() {
|
|
if (!confirm(t('regenConfirm'))) return;
|
|
try {
|
|
const resp = await fetch('/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-error').style.display = 'none';
|
|
document.getElementById('change-modal').classList.add('open');
|
|
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 fetch('/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 fetch('/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();
|
|
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>
|