From f2344b75438bdb6b97c9dcd77a70356116965272 Mon Sep 17 00:00:00 2001 From: voson Date: Sat, 21 Mar 2026 11:12:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(secrets-mcp):=20=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E9=A1=B5=E3=80=81audit=5Flog=20user=5Fid=E3=80=81OAuth=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=B8=8E=E4=BB=AA=E8=A1=A8=E7=9B=98=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audit_log 增加 user_id;业务写审计透传 user_id - Web /audit 与侧边栏;Dashboard 版本 footer 贴底(margin-top: auto) - 停止 API Key 鉴权成功写入登录审计 - 文档、CI、release-check 配套更新 Made-with: Cursor --- .gitea/workflows/secrets.yml | 30 ++-- AGENTS.md | 4 +- Cargo.lock | 2 +- README.md | 2 +- crates/secrets-core/src/audit.rs | 11 +- crates/secrets-core/src/db.rs | 3 + crates/secrets-core/src/models.rs | 14 ++ crates/secrets-core/src/service/add.rs | 1 + crates/secrets-core/src/service/audit_log.rs | 23 +++ crates/secrets-core/src/service/delete.rs | 3 +- crates/secrets-core/src/service/mod.rs | 1 + crates/secrets-core/src/service/rollback.rs | 1 + crates/secrets-core/src/service/update.rs | 1 + crates/secrets-mcp/Cargo.toml | 2 +- crates/secrets-mcp/src/auth.rs | 20 --- crates/secrets-mcp/src/web.rs | 69 +++++++++ crates/secrets-mcp/templates/audit.html | 142 +++++++++++++++++++ crates/secrets-mcp/templates/dashboard.html | 92 ++++++++---- scripts/release-check.sh | 9 +- 19 files changed, 361 insertions(+), 69 deletions(-) create mode 100644 crates/secrets-core/src/service/audit_log.rs create mode 100644 crates/secrets-mcp/templates/audit.html diff --git a/.gitea/workflows/secrets.yml b/.gitea/workflows/secrets.yml index bfc04d2..fe19ae6 100644 --- a/.gitea/workflows/secrets.yml +++ b/.gitea/workflows/secrets.yml @@ -59,12 +59,10 @@ jobs: echo "将创建新版本 ${tag}" fi - - name: 严格拦截重复版本 + - name: 检测重复版本 if: steps.ver.outputs.tag_exists == 'true' run: | - echo "错误: 版本 ${{ steps.ver.outputs.tag }} 已存在,禁止重复发版。" - echo "请先 bump crates/secrets-mcp/Cargo.toml 中的 version,并执行 cargo build 同步 Cargo.lock。" - exit 1 + echo "提示: 版本 ${{ steps.ver.outputs.tag }} 已存在,将复用现有 tag 继续构建。" - name: 创建 Tag if: steps.ver.outputs.tag_exists == 'false' @@ -230,16 +228,32 @@ jobs: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | [ -z "$RELEASE_TOKEN" ] && exit 0 + command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq) + tag="${{ needs.version.outputs.tag }}" bin="target/${{ env.MUSL_TARGET }}/release/${{ env.MCP_BINARY }}" archive="${{ env.MCP_BINARY }}-${tag}-x86_64-linux-musl.tar.gz" tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")" sha256sum "$archive" > "${archive}.sha256" - release_url="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" + release_api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}" + release_url="${release_api}/assets" curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ - -F "attachment=@${archive}" "$release_url" - curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ - -F "attachment=@${archive}.sha256" "$release_url" + "$release_api" -o /tmp/release-assets.json + + for asset_name in "$archive" "${archive}.sha256"; do + asset_ids=$(jq -r --arg name "$asset_name" '.assets[]? | select(.name == $name) | .id' /tmp/release-assets.json) + if [ -n "$asset_ids" ]; then + while IFS= read -r asset_id; do + [ -z "$asset_id" ] && continue + echo "删除已有产物: ${asset_name} (${asset_id})" + curl -fsS -X DELETE -H "Authorization: token $RELEASE_TOKEN" \ + "${release_url}/${asset_id}" + done <<< "$asset_ids" + fi + + curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ + -F "attachment=@${asset_name}" "$release_url" + done deploy-mcp: name: 部署 secrets-mcp diff --git a/AGENTS.md b/AGENTS.md index 6687844..aab0db8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ 1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock`、`secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。 2. 发版前检查 `crates/secrets-mcp/Cargo.toml` 的 `version`,再查 tag:`git tag -l 'secrets-mcp-*'`。 -3. 若当前版本对应 tag 已存在,须先 bump `version`,再 `cargo build` 同步 `Cargo.lock` 后提交。 +3. 若当前版本对应 tag 已存在,默认允许复用现有 tag 继续构建;仅在需要新的发布版本时再 bump `version` 并 `cargo build` 同步 `Cargo.lock`。 4. 提交前优先运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。 ## 项目结构 @@ -149,7 +149,7 @@ git tag -l 'secrets-mcp-*' ## CI/CD - **触发**:任意分支 `push`,且路径含 `crates/**`、`deploy/**`、根目录 `Cargo.toml`、`Cargo.lock`(见 `.gitea/workflows/secrets.yml`)。 -- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;若远程已存在同名 `secrets-mcp-` tag,**工作流失败**(须先 bump 版本并 `cargo build` 同步 `Cargo.lock`);否则由 CI 创建并推送该 tag。 +- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;若远程已存在同名 `secrets-mcp-` tag,则复用现有 tag 继续构建;否则由 CI 创建并推送该 tag。 - **质量与构建**:`fmt` / `clippy --locked` / `test --locked` → `x86_64-unknown-linux-musl` 发布构建 `secrets-mcp`。 - **Release(可选)**:`secrets.RELEASE_TOKEN`(Gitea PAT)用于创建草稿 Release、上传 `tar.gz` + `.sha256`、构建成功后发布;未配置则跳过 API Release,仅 tag + 构建。 - **部署(可选)**:仅 `main`、`feat/mcp`、`mcp` 分支在构建成功时跑 `deploy-mcp`;需 `vars.DEPLOY_HOST`、`vars.DEPLOY_USER`、`secrets.DEPLOY_SSH_KEY`。勿把 OAuth/DB 等写进 workflow,用 `deploy/.env.example` 在目标机配置。 diff --git a/Cargo.lock b/Cargo.lock index 665a8db..893bf79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1949,7 +1949,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "askama", diff --git a/README.md b/README.md index 39bc95a..7adc23f 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ deploy/ # systemd、.env 示例 见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)。 - **触发**:任意分支 `push`,且变更路径包含 `crates/**`、`deploy/**`、根目录 `Cargo.toml` / `Cargo.lock`。 -- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → **若 `secrets-mcp-` 的 tag 已存在则整次运行失败**(避免重复发版)→ 否则自动打 tag → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl` 的 `secrets-mcp`。 +- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → 若 `secrets-mcp-` 的 tag 已存在则**复用现有 tag 继续构建**,否则自动打 tag → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl` 的 `secrets-mcp`。 - **Release(可选)**:配置仓库 Secret `RELEASE_TOKEN`(Gitea PAT,明文勿 base64)时,会通过 API 创建**草稿** Release、在 Linux 构建成功后上传 `tar.gz` 与 `.sha256`,再自动将草稿**正式发布**;未配置则跳过创建 Release 与产物上传,仅保留 tag 与构建结果。 - **部署(可选)**:仅在 `main`、`feat/mcp` 或 `mcp` 分支且构建成功时,若已配置 `vars.DEPLOY_HOST`、`vars.DEPLOY_USER` 与 `secrets.DEPLOY_SSH_KEY`,则 `deploy-mcp` 通过 SCP/SSH 更新目标机二进制并 `systemctl restart secrets-mcp`。 - **通知(可选)**:`vars.WEBHOOK_URL` 为飞书 Webhook 时,构建/部署/发布节点会推送简要状态。 diff --git a/crates/secrets-core/src/audit.rs b/crates/secrets-core/src/audit.rs index f290f2d..b8e319f 100644 --- a/crates/secrets-core/src/audit.rs +++ b/crates/secrets-core/src/audit.rs @@ -36,9 +36,10 @@ pub async fn log_login( let actor = current_actor(); let detail = login_detail(user_id, provider, client_ip, user_agent); let result: Result<_, sqlx::Error> = sqlx::query( - "INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \ - VALUES ($1, $2, $3, $4, $5, $6)", + "INSERT INTO audit_log (user_id, action, namespace, kind, name, detail, actor) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", ) + .bind(user_id) .bind(ACTION_LOGIN) .bind(NAMESPACE_AUTH) .bind(kind) @@ -58,6 +59,7 @@ pub async fn log_login( /// Write an audit entry within an existing transaction. pub async fn log_tx( tx: &mut Transaction<'_, Postgres>, + user_id: Option, action: &str, namespace: &str, kind: &str, @@ -66,9 +68,10 @@ pub async fn log_tx( ) { let actor = current_actor(); let result: Result<_, sqlx::Error> = sqlx::query( - "INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \ - VALUES ($1, $2, $3, $4, $5, $6)", + "INSERT INTO audit_log (user_id, action, namespace, kind, name, detail, actor) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", ) + .bind(user_id) .bind(action) .bind(namespace) .bind(kind) diff --git a/crates/secrets-core/src/db.rs b/crates/secrets-core/src/db.rs index 9104e03..5b60da6 100644 --- a/crates/secrets-core/src/db.rs +++ b/crates/secrets-core/src/db.rs @@ -67,6 +67,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { -- ── audit_log: append-only operation log ───────────────────────────────── CREATE TABLE IF NOT EXISTS audit_log ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id UUID, action VARCHAR(32) NOT NULL, namespace VARCHAR(64) NOT NULL, kind VARCHAR(64) NOT NULL, @@ -76,8 +77,10 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id UUID; CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind); + CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL; -- ── entries_history ─────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS entries_history ( diff --git a/crates/secrets-core/src/models.rs b/crates/secrets-core/src/models.rs index 9b4e25f..6b53a58 100644 --- a/crates/secrets-core/src/models.rs +++ b/crates/secrets-core/src/models.rs @@ -174,6 +174,20 @@ pub struct OauthAccount { pub created_at: DateTime, } +/// A single audit log row, optionally scoped to a business user. +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct AuditLogEntry { + pub id: i64, + pub user_id: Option, + pub action: String, + pub namespace: String, + pub kind: String, + pub name: String, + pub detail: Value, + pub actor: String, + pub created_at: DateTime, +} + // ── TOML ↔ JSON value conversion ────────────────────────────────────────────── /// Convert a serde_json Value to a toml Value. diff --git a/crates/secrets-core/src/service/add.rs b/crates/secrets-core/src/service/add.rs index 6100bb9..d3206c0 100644 --- a/crates/secrets-core/src/service/add.rs +++ b/crates/secrets-core/src/service/add.rs @@ -346,6 +346,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> crate::audit::log_tx( &mut tx, + params.user_id, "add", params.namespace, params.kind, diff --git a/crates/secrets-core/src/service/audit_log.rs b/crates/secrets-core/src/service/audit_log.rs new file mode 100644 index 0000000..9bd5692 --- /dev/null +++ b/crates/secrets-core/src/service/audit_log.rs @@ -0,0 +1,23 @@ +use anyhow::Result; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::AuditLogEntry; + +pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result> { + let limit = limit.clamp(1, 200); + + let rows = sqlx::query_as( + "SELECT id, user_id, action, namespace, kind, name, detail, actor, created_at \ + FROM audit_log \ + WHERE user_id = $1 OR (user_id IS NULL AND detail->>'user_id' = $1::text) \ + ORDER BY created_at DESC, id DESC \ + LIMIT $2", + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} diff --git a/crates/secrets-core/src/service/delete.rs b/crates/secrets-core/src/service/delete.rs index 1da8918..c71da5f 100644 --- a/crates/secrets-core/src/service/delete.rs +++ b/crates/secrets-core/src/service/delete.rs @@ -137,7 +137,7 @@ async fn delete_one( }; snapshot_and_delete(&mut tx, namespace, kind, name, &row, user_id).await?; - crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await; + crate::audit::log_tx(&mut tx, user_id, "delete", namespace, kind, name, json!({})).await; tx.commit().await?; Ok(DeleteResult { @@ -240,6 +240,7 @@ async fn delete_bulk( .await?; crate::audit::log_tx( &mut tx, + user_id, "delete", namespace, &row.kind, diff --git a/crates/secrets-core/src/service/mod.rs b/crates/secrets-core/src/service/mod.rs index 5ef4e9e..127455e 100644 --- a/crates/secrets-core/src/service/mod.rs +++ b/crates/secrets-core/src/service/mod.rs @@ -1,5 +1,6 @@ pub mod add; pub mod api_key; +pub mod audit_log; pub mod delete; pub mod env_map; pub mod export; diff --git a/crates/secrets-core/src/service/rollback.rs b/crates/secrets-core/src/service/rollback.rs index 1469bb4..56d6605 100644 --- a/crates/secrets-core/src/service/rollback.rs +++ b/crates/secrets-core/src/service/rollback.rs @@ -274,6 +274,7 @@ pub async fn run( crate::audit::log_tx( &mut tx, + user_id, "rollback", namespace, kind, diff --git a/crates/secrets-core/src/service/update.rs b/crates/secrets-core/src/service/update.rs index 8a4baac..d2ea926 100644 --- a/crates/secrets-core/src/service/update.rs +++ b/crates/secrets-core/src/service/update.rs @@ -241,6 +241,7 @@ pub async fn run( crate::audit::log_tx( &mut tx, + params.user_id, "update", params.namespace, params.kind, diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index cadd30f..9aa6925 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.1.6" +version = "0.1.7" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/auth.rs b/crates/secrets-mcp/src/auth.rs index 3b0ded5..304f05e 100644 --- a/crates/secrets-mcp/src/auth.rs +++ b/crates/secrets-mcp/src/auth.rs @@ -9,7 +9,6 @@ use axum::{ use sqlx::PgPool; use uuid::Uuid; -use secrets_core::audit::log_login; use secrets_core::service::api_key::validate_api_key; /// Injected into request extensions after Bearer token validation. @@ -35,15 +34,6 @@ fn log_client_ip(req: &Request) -> Option { .map(|c| c.ip().to_string()) } -fn log_user_agent(req: &Request) -> Option { - req.headers() - .get(axum::http::header::USER_AGENT) - .and_then(|v| v.to_str().ok()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - /// Axum middleware that validates Bearer API keys for the /mcp route. /// Passes all non-MCP paths through without authentication. pub async fn bearer_auth_middleware( @@ -54,7 +44,6 @@ pub async fn bearer_auth_middleware( let path = req.uri().path(); let method = req.method().as_str(); let client_ip = log_client_ip(&req); - let user_agent = log_user_agent(&req); // Only authenticate /mcp paths if !path.starts_with("/mcp") { @@ -95,15 +84,6 @@ pub async fn bearer_auth_middleware( match validate_api_key(&pool, raw_key).await { Ok(Some(user_id)) => { - log_login( - &pool, - "api_key", - "bearer", - user_id, - client_ip.as_deref(), - user_agent.as_deref(), - ) - .await; tracing::debug!(?user_id, "api key authenticated"); let mut req = req; req.extensions_mut().insert(AuthUser { user_id }); diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index 6b5914c..3bb8455 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -17,6 +17,7 @@ use secrets_core::audit::log_login; use secrets_core::crypto::hex; use secrets_core::service::{ api_key::{ensure_api_key, regenerate_api_key}, + audit_log::list_for_user, user::{ OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id, unbind_oauth_account, update_user_key_setup, @@ -50,6 +51,22 @@ struct DashboardTemplate { version: &'static str, } +#[derive(Template)] +#[template(path = "audit.html")] +struct AuditPageTemplate { + user_name: String, + user_email: String, + entries: Vec, + version: &'static str, +} + +struct AuditEntryView { + created_at: String, + action: String, + target: String, + detail: String, +} + // ── App state helpers ───────────────────────────────────────────────────────── fn google_cfg(state: &AppState) -> Option<&OAuthConfig> { @@ -103,6 +120,7 @@ pub fn web_router() -> Router { .route("/auth/google/callback", get(auth_google_callback)) .route("/auth/logout", post(auth_logout)) .route("/dashboard", get(dashboard)) + .route("/audit", get(audit_page)) .route("/account/bind/google", get(account_bind_google)) .route( "/account/bind/google/callback", @@ -364,6 +382,49 @@ async fn dashboard( render_template(tmpl) } +async fn audit_page( + State(state): State, + session: Session, +) -> Result { + let Some(user_id) = current_user_id(&session).await else { + return Ok(Redirect::to("/").into_response()); + }; + + let user = match get_user_by_id(&state.pool, user_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + Some(u) => u, + None => return Ok(Redirect::to("/").into_response()), + }; + + let rows = list_for_user(&state.pool, user_id, 100) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to load audit log for user"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let entries = rows + .into_iter() + .map(|row| AuditEntryView { + created_at: row.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(), + action: row.action, + target: format_audit_target(&row.namespace, &row.kind, &row.name), + detail: serde_json::to_string_pretty(&row.detail).unwrap_or_else(|_| "{}".to_string()), + }) + .collect(); + + let tmpl = AuditPageTemplate { + user_name: user.name.clone(), + user_email: user.email.clone().unwrap_or_default(), + entries, + version: env!("CARGO_PKG_VERSION"), + }; + + render_template(tmpl) +} + // ── Account bind/unbind ─────────────────────────────────────────────────────── async fn account_bind_google( @@ -577,3 +638,11 @@ fn render_template(tmpl: T) -> Result { })?; Ok(Html(html).into_response()) } + +fn format_audit_target(namespace: &str, kind: &str, name: &str) -> String { + if namespace == "auth" { + format!("{}/{}", kind, name) + } else { + format!("[{}/{}] {}", namespace, kind, name) + } +} diff --git a/crates/secrets-mcp/templates/audit.html b/crates/secrets-mcp/templates/audit.html new file mode 100644 index 0000000..ba299df --- /dev/null +++ b/crates/secrets-mcp/templates/audit.html @@ -0,0 +1,142 @@ + + + + + + + Secrets — Audit + + + +
+ + +
+
+ + {{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %} +
+ +
+
+ +
+
+
我的审计
+
展示最近 100 条与当前用户相关的新审计记录。
+ + {% if entries.is_empty() %} +
暂无审计记录。
+ {% else %} + + + + + + + + + + + {% for entry in entries %} + + + + + + + {% endfor %} + +
时间动作目标详情
{{ entry.created_at }}{{ entry.action }}{{ entry.target }}
{{ entry.detail }}
+ {% endif %} +
+
+
+
+ + diff --git a/crates/secrets-mcp/templates/dashboard.html b/crates/secrets-mcp/templates/dashboard.html index c6e50d6..ff5bda1 100644 --- a/crates/secrets-mcp/templates/dashboard.html +++ b/crates/secrets-mcp/templates/dashboard.html @@ -16,13 +16,29 @@ } 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; } + .layout { display: flex; min-height: 100vh; } + .sidebar { + width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border); + padding: 24px 16px; display: flex; flex-direction: column; gap: 20px; + } + .sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600; + color: var(--text); text-decoration: none; padding: 0 10px; } + .sidebar-logo span { color: var(--accent); } + .sidebar-menu { display: flex; flex-direction: column; gap: 6px; } + .sidebar-link { + padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none; + border: 1px solid transparent; font-size: 13px; font-weight: 500; + } + .sidebar-link:hover { background: var(--surface2); color: var(--text); } + .sidebar-link.active { + background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35); + } + .content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; } + .topbar { + background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px; + display: flex; align-items: center; gap: 12px; min-height: 52px; + } + .topbar-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); @@ -32,11 +48,19 @@ 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 content column */ .main { display: flex; flex-direction: column; align-items: center; - padding: 48px 24px 24px; min-height: calc(100vh - 52px); } + padding: 24px 20px 8px; min-height: 0; } + .app-footer { + margin-top: auto; + text-align: center; + padding: 4px 20px 12px; + font-size: 12px; + color: #9da7b3; + font-family: 'JetBrains Mono', monospace; + } .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; - padding: 32px; width: 100%; max-width: 980px; } + padding: 24px; width: 100%; max-width: 980px; } .card-title { font-size: 18px; font-weight: 600; margin-bottom: 24px; } /* Form */ .field { margin-bottom: 12px; } @@ -123,28 +147,40 @@ 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; } + @media (max-width: 900px) { + .layout { flex-direction: column; } + .sidebar { + width: 100%; border-right: none; border-bottom: 1px solid var(--border); + padding: 16px; gap: 14px; + } + .sidebar-menu { flex-direction: row; } + .sidebar-link { flex: 1; text-align: center; } } - .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; + @media (max-width: 720px) { + .config-tabs { grid-template-columns: 1fr; } + .topbar { padding: 12px 16px; flex-wrap: wrap; } + .main { padding: 16px 12px 6px; } + .app-footer { padding: 4px 12px 10px; } + .card { padding: 18px; } } + - +
@@ -258,9 +294,11 @@
- +
{{ version }}
+ +