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

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

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

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@
.cursor/
# Google OAuth 下载的 JSON 凭据文件
client_secret_*.apps.googleusercontent.com.json
*.pem

12
.vscode/tasks.json vendored
View File

@@ -1,23 +1,19 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "shell",
"command": "cargo build --workspace --locked",
"group": { "kind": "build", "isDefault": true }
},
{
"label": "mcp: build",
"type": "shell",
"command": "cargo build --locked -p secrets-mcp",
"group": "build"
"group": "build",
"options": {
"envFile": "${workspaceFolder}/.env"
}
},
{
"label": "mcp: run",
"type": "shell",
"command": "cargo run --locked -p secrets-mcp",
"dependsOn": "mcp: build",
"options": {
"envFile": "${workspaceFolder}/.env"
}

View File

@@ -99,6 +99,11 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_entries_history_ns_kind_name
ON entries_history(namespace, kind, name, version DESC);
-- Backfill: add user_id to entries_history for multi-tenant isolation
ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID;
CREATE INDEX IF NOT EXISTS idx_entries_history_user_id
ON entries_history(user_id) WHERE user_id IS NOT NULL;
-- ── secrets_history: field-level snapshot ────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
@@ -159,6 +164,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
pub struct EntrySnapshotParams<'a> {
pub entry_id: uuid::Uuid,
pub user_id: Option<uuid::Uuid>,
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
@@ -175,8 +181,8 @@ pub async fn snapshot_entry_history(
let actor = current_actor();
sqlx::query(
"INSERT INTO entries_history \
(entry_id, namespace, kind, name, version, action, tags, metadata, actor) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
(entry_id, namespace, kind, name, version, action, tags, metadata, actor, user_id) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
)
.bind(p.entry_id)
.bind(p.namespace)
@@ -187,6 +193,7 @@ pub async fn snapshot_entry_history(
.bind(p.tags)
.bind(p.metadata)
.bind(&actor)
.bind(p.user_id)
.execute(&mut **tx)
.await?;
Ok(())

View File

@@ -215,6 +215,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
&mut tx,
db::EntrySnapshotParams {
entry_id: ex.id,
user_id: params.user_id,
namespace: params.namespace,
kind: params.kind,
name: params.name,
@@ -275,6 +276,26 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
.fetch_one(&mut *tx)
.await?;
if existing.is_none()
&& let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id,
user_id: params.user_id,
namespace: params.namespace,
kind: params.kind,
name: params.name,
version: new_entry_version,
action: "create",
tags: params.tags,
metadata: &metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history on create");
}
if existing.is_some() {
#[derive(sqlx::FromRow)]
struct ExistingField {

View File

@@ -33,7 +33,15 @@ pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult
let kind = params
.kind
.ok_or_else(|| anyhow::anyhow!("--kind is required when --name is specified"))?;
delete_one(pool, params.namespace, kind, name, params.user_id).await
delete_one(
pool,
params.namespace,
kind,
name,
params.dry_run,
params.user_id,
)
.await
}
None => {
delete_bulk(
@@ -53,8 +61,48 @@ async fn delete_one(
namespace: &str,
kind: &str,
name: &str,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
if dry_run {
let exists: bool = if let Some(uid) = user_id {
sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4)",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3)",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_one(pool)
.await?
};
let deleted = if exists {
vec![DeletedEntry {
namespace: namespace.to_string(),
kind: kind.to_string(),
name: name.to_string(),
}]
} else {
vec![]
};
return Ok(DeleteResult {
deleted,
dry_run: true,
});
}
let mut tx = pool.begin().await?;
let row: Option<EntryRow> = if let Some(uid) = user_id {
@@ -88,7 +136,7 @@ async fn delete_one(
});
};
snapshot_and_delete(&mut tx, namespace, kind, name, &row).await?;
snapshot_and_delete(&mut tx, namespace, kind, name, &row, user_id).await?;
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await;
tx.commit().await?;
@@ -186,7 +234,10 @@ async fn delete_bulk(
metadata: row.metadata.clone(),
};
let mut tx = pool.begin().await?;
snapshot_and_delete(&mut tx, namespace, &row.kind, &row.name, &entry_row).await?;
snapshot_and_delete(
&mut tx, namespace, &row.kind, &row.name, &entry_row, user_id,
)
.await?;
crate::audit::log_tx(
&mut tx,
"delete",
@@ -216,11 +267,13 @@ async fn snapshot_and_delete(
kind: &str,
name: &str,
row: &EntryRow,
user_id: Option<Uuid>,
) -> Result<()> {
if let Err(e) = db::snapshot_entry_history(
tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id,
namespace,
kind,
name,

View File

@@ -107,11 +107,9 @@ fn env_prefix(entry: &Entry, prefix: &str) -> String {
if prefix.is_empty() {
name_part
} else {
format!(
"{}_{}",
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
name_part
)
let normalized = prefix.to_uppercase().replace(['-', '.', ' '], "_");
let normalized = normalized.trim_end_matches('_');
format!("{}_{}", normalized, name_part)
}
}

View File

@@ -17,7 +17,7 @@ pub async fn run(
kind: &str,
name: &str,
limit: u32,
_user_id: Option<Uuid>,
user_id: Option<Uuid>,
) -> Result<Vec<HistoryEntry>> {
#[derive(sqlx::FromRow)]
struct Row {
@@ -27,17 +27,32 @@ pub async fn run(
created_at: chrono::DateTime<chrono::Utc>,
}
let rows: Vec<Row> = sqlx::query_as(
"SELECT version, action, actor, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
ORDER BY id DESC LIMIT $4",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(limit as i64)
.fetch_all(pool)
.await?;
let rows: Vec<Row> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT version, action, actor, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id = $4 \
ORDER BY id DESC LIMIT $5",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(uid)
.bind(limit as i64)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT version, action, actor, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NULL \
ORDER BY id DESC LIMIT $4",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(limit as i64)
.fetch_all(pool)
.await?
};
Ok(rows
.into_iter()

View File

@@ -21,7 +21,7 @@ pub async fn run(
name: &str,
to_version: Option<i64>,
master_key: &[u8; 32],
_user_id: Option<Uuid>,
user_id: Option<Uuid>,
) -> Result<RollbackResult> {
#[derive(sqlx::FromRow)]
struct EntryHistoryRow {
@@ -33,21 +33,49 @@ pub async fn run(
}
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
if let Some(uid) = user_id {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
AND user_id = $5 ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(ver)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(ver)
.fetch_optional(pool)
.await?
}
} else if let Some(uid) = user_id {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
ORDER BY id DESC LIMIT 1",
WHERE namespace = $1 AND kind = $2 AND name = $3 \
AND user_id = $4 ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(ver)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 ORDER BY id DESC LIMIT 1",
WHERE namespace = $1 AND kind = $2 AND name = $3 \
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
@@ -70,14 +98,13 @@ pub async fn run(
#[derive(sqlx::FromRow)]
struct SecretHistoryRow {
secret_id: Uuid,
field_name: String,
encrypted: Vec<u8>,
action: String,
}
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
"SELECT secret_id, field_name, encrypted, action FROM secrets_history \
"SELECT field_name, encrypted, action FROM secrets_history \
WHERE entry_id = $1 AND entry_version = $2 ORDER BY field_name",
)
.bind(snap.entry_id)
@@ -106,21 +133,38 @@ pub async fn run(
tags: Vec<String>,
metadata: Value,
}
let live: Option<LiveEntry> = sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?;
if let Some(ref lr) = live {
// Query live entry with correct user_id scoping to avoid PK conflicts
let live: Option<LiveEntry> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
};
let entry_id = if let Some(ref lr) = live {
// Snapshot current state before overwriting
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: lr.id,
user_id,
namespace,
kind,
name,
@@ -164,27 +208,55 @@ pub async fn run(
tracing::warn!(error = %e, "failed to snapshot secret field before rollback");
}
}
}
sqlx::query(
"INSERT INTO entries (id, namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) \
ON CONFLICT (namespace, kind, name) WHERE user_id IS NULL DO UPDATE SET \
tags = EXCLUDED.tags, metadata = EXCLUDED.metadata, \
version = entries.version + 1, updated_at = NOW()",
)
.bind(snap.entry_id)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(snap.version)
.execute(&mut *tx)
.await?;
// Update the existing row in-place to preserve its primary key and user_id
sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, \
updated_at = NOW() WHERE id = $3",
)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(lr.id)
.execute(&mut *tx)
.await?;
lr.id
} else {
// No live entry — insert a fresh one with a new UUID
if let Some(uid) = user_id {
sqlx::query_scalar(
"INSERT INTO entries \
(user_id, namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING id",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
"INSERT INTO entries \
(namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING id",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
}
};
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(snap.entry_id)
.bind(entry_id)
.execute(&mut *tx)
.await?;
@@ -192,17 +264,12 @@ pub async fn run(
if f.action == "delete" {
continue;
}
sqlx::query(
"INSERT INTO secrets (id, entry_id, field_name, encrypted) VALUES ($1, $2, $3, $4) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
encrypted = EXCLUDED.encrypted, version = secrets.version + 1, updated_at = NOW()",
)
.bind(f.secret_id)
.bind(snap.entry_id)
.bind(&f.field_name)
.bind(&f.encrypted)
.execute(&mut *tx)
.await?;
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(entry_id)
.bind(&f.field_name)
.bind(&f.encrypted)
.execute(&mut *tx)
.await?;
}
crate::audit::log_tx(

View File

@@ -80,6 +80,7 @@ pub async fn run(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id: params.user_id,
namespace: params.namespace,
kind: params.kind,
name: params.name,

View File

@@ -555,9 +555,9 @@ impl SecretsService {
}
#[tool(
description = "Preview the environment variable mapping that would be injected when \
running a command. Requires X-Encryption-Key header. \
Shows variable names and sources, useful for debugging."
description = "Build the environment variable map from entry secrets with decrypted \
plaintext values. Requires X-Encryption-Key header. \
Returns a JSON object of VAR_NAME -> plaintext_value ready for injection."
)]
async fn secrets_env_map(
&self,

View File

@@ -1,8 +1,9 @@
use askama::Template;
use axum::{
Json, Router,
body::Body,
extract::{Path, Query, State},
http::StatusCode,
http::{StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
@@ -33,6 +34,7 @@ const SESSION_LOGIN_PROVIDER: &str = "login_provider";
#[template(path = "login.html")]
struct LoginTemplate {
has_google: bool,
version: &'static str,
}
#[derive(Template)]
@@ -42,6 +44,7 @@ struct DashboardTemplate {
user_email: String,
has_passphrase: bool,
base_url: String,
version: &'static str,
}
// ── App state helpers ─────────────────────────────────────────────────────────
@@ -63,6 +66,11 @@ async fn current_user_id(session: &Session) -> Option<Uuid> {
pub fn web_router() -> Router<AppState> {
Router::new()
.route("/favicon.svg", get(favicon_svg))
.route(
"/favicon.ico",
get(|| async { Redirect::permanent("/favicon.svg") }),
)
.route("/", get(login_page))
.route("/auth/google", get(auth_google))
.route("/auth/google/callback", get(auth_google_callback))
@@ -80,6 +88,15 @@ pub fn web_router() -> Router<AppState> {
.route("/api/apikey/regenerate", post(api_apikey_regenerate))
}
async fn favicon_svg() -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/svg+xml")
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(include_str!("../static/favicon.svg")))
.expect("favicon response")
}
// ── Login page ────────────────────────────────────────────────────────────────
async fn login_page(
@@ -92,6 +109,7 @@ async fn login_page(
let tmpl = LoginTemplate {
has_google: state.google_config.is_some(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
@@ -272,20 +290,24 @@ async fn dashboard(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/").into_response());
};
let user = get_user_by_id(&state.pool, user_id)
let user = match get_user_by_id(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
{
Some(u) => u,
None => return Ok(Redirect::to("/").into_response()),
};
let tmpl = DashboardTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
has_passphrase: user.key_salt.is_some(),
base_url: state.base_url.clone(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<text x="16" y="23" text-anchor="middle" font-family="system-ui,Segoe UI,sans-serif" font-size="22" font-weight="700" fill="#58a6ff">S</text>
</svg>

After

Width:  |  Height:  |  Size: 212 B

View File

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

View File

@@ -3,10 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — Sign In</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117;
--surface: #161b22;
@@ -29,9 +30,6 @@
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px; }
.lang-btn.active { background: var(--border); color: var(--text); }
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 32px; }
.logo-icon { font-family: 'JetBrains Mono', monospace; font-size: 24px; color: var(--accent); }
.logo-text { font-family: 'JetBrains Mono', monospace; font-size: 18px; font-weight: 600; }
h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 32px; }
.btn {
@@ -56,10 +54,6 @@
<button class="lang-btn" onclick="setLang('en')">EN</button>
</div>
</div>
<div class="logo">
<span class="logo-icon">🔐</span>
<span class="logo-text">secrets</span>
</div>
<h1 data-i18n="title">登录</h1>
<p class="subtitle" data-i18n="subtitle">安全管理你的跨设备 secrets。</p>