diff --git a/README.en.md b/README.en.md index 2826739..f1b9926 100644 --- a/README.en.md +++ b/README.en.md @@ -87,7 +87,7 @@ Container swap is controlled by `incus.swap_ratio` (default 0.5 — a 1 GiB memo Users can define an init script in the panel. It runs as root inside the container after reinstall, with output written to `/var/log/vpsmgr-init.log`. -In the admin user list, **clicking a username** (a deliberately low-key entry point) opens a picker where the operator can assign that user a dedicated accent color, or clear it back to the default. The color is applied to that user's "log in" button and tints their user-panel background (adapted for both light and dark mode). Users cannot change it themselves. +In the admin user list, **clicking a username** (a deliberately low-key entry point) opens a picker where the operator can assign that user a dedicated accent color, or clear it back to the default. The color themes that user's "log in" button and, during an operator impersonation session, their user-panel background (adapted for both light and dark mode) so the operator can tell users apart. Users logging in with their own password always see the default theme. Users cannot change the color themselves. ## Additional Images diff --git a/README.md b/README.md index 3dc8098..3536164 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - 管理员面板:用户、配额、域名、IPv6 地址池、SSH 公钥与审计日志;支持"以用户身份登录"接入用户面板 - 用户面板:电源控制、自助重装 Debian 13/AlmaLinux 9/openSUSE Leap 16/Arch Linux、域名与 SSH 密钥管理、加密便签(可导出)、时光机(检查点快照,回滚即丢弃此后的检查点) -- 管理员可为每个用户分配专属颜色(在用户列表中点击用户名):该用户的「登录面板」按钮与其用户面板背景会采用此颜色,方便代客登录时区分不同用户 +- 管理员可为每个用户分配专属颜色(在用户列表中点击用户名):该用户的「登录面板」按钮采用此颜色,管理员代客登录时其用户面板也以此颜色渲染,方便区分不同用户;用户自行登录始终显示默认主题 - CPU、内存、磁盘支持超售;配额修改实时生效,无需重启容器 - 容器网络严格隔离;IPv6 前缀模式下每个容器获得独立 `/112`,可继续划分子网 - 流量按上下行合计,超额后双向限速至 1 Mbps diff --git a/docs/architecture.md b/docs/architecture.md index 8f43622..4a2f2bc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -204,7 +204,9 @@ The panel daemon runs as the dedicated unprivileged `vps` system user black-in-light / white-in-dark, themed when a color is set) and the **user panel**, which injects it as a CSS-variable override — `--accent`/`--head` recolor headings, primary buttons and links, and the page background gets a - soft `color-mix()` tint that stays readable in both light and dark mode. Only + soft `color-mix()` tint that stays readable in both light and dark mode. The + user-panel tint renders **only for operator impersonation sessions**, so a + user logging in with their own password always sees the default theme. Only the admin can set it; users cannot. Setting/clearing is audited as `color.update` under `000+`. - **Snapshots ("时光机" / time machine)**: each user can keep up to diff --git a/src/internal/panel/handlers.go b/src/internal/panel/handlers.go index 56265cd..2aa607d 100644 --- a/src/internal/panel/handlers.go +++ b/src/internal/panel/handlers.go @@ -195,6 +195,12 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { d := s.buildData(u, "", "") d.Impersonated = s.isImpersonated(r) d.AdminPrefix = "/" + s.cfg.Panel.AdminPath + // The per-user accent color exists so an impersonating operator can tell + // users apart at a glance. Users logging in with their own password get the + // default theme instead of their assigned color. + if !d.Impersonated { + d.ThemeColor = "" + } s.render(w, r, "overview.html", d) } diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index 2873954..1516e1a 100644 --- a/src/internal/panel/panel_test.go +++ b/src/internal/panel/panel_test.go @@ -364,7 +364,7 @@ func TestOverviewThemeColor(t *testing.T) { "color-mix(in srgb, #16a34a 13%, #ffffff)", "color-mix(in srgb, #16a34a 16%, #14161a)", "color-mix(in srgb, #16a34a 5%, #ffffff)", - "border-bottom:3px solid #16a34a", + "border-bottom:1px solid #16a34a", } { if !strings.Contains(themed, want) { t.Errorf("themed overview missing %q", want) @@ -372,6 +372,61 @@ func TestOverviewThemeColor(t *testing.T) { } } +// TestOverviewThemeColorOnlyWhenImpersonated verifies the per-user accent +// color is served only to operator impersonation sessions: a user logging in +// with their own password sees the default theme even when a color is +// assigned. +func TestOverviewThemeColorOnlyWhenImpersonated(t *testing.T) { + srv, d := newTestServer(t) + h := srv.Handler() + prefix := "/" + testSecret + hash, _ := pw.Hash("pw") + u, err := d.CreateUser("alice", hash, "10.42.0.2", 1, 30001, 10000, 1, 1024, 10) + if err != nil { + t.Fatal(err) + } + if err := d.UpdateUserColor(u.ID, "#16a34a"); err != nil { + t.Fatal(err) + } + + // User's own login session: default theme, no tinted CSS override. + rr := doReq(t, h, http.MethodPost, prefix+"/login", + url.Values{"username": {"alice"}, "password": {"pw"}}, nil) + if rr.Code != http.StatusFound { + t.Fatalf("login = %d, want 302", rr.Code) + } + var own *http.Cookie + for _, c := range rr.Result().Cookies() { + if c.Name == "vpsmgr_session" { + own = c + } + } + rr = doReq(t, h, http.MethodGet, prefix, nil, own) + if rr.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want 200", prefix, rr.Code) + } + if body := rr.Body.String(); strings.Contains(body, "--accent:#16a34a") { + t.Error("own login should render the default theme, not the assigned color") + } + + // Operator impersonation session: the overview carries the assigned color. + sess, err := d.CreateImpersonatedSession(u.ID, 3) + if err != nil { + t.Fatal(err) + } + imp := &http.Cookie{Name: "vpsmgr_session", Value: sess.Token, Path: prefix} + rr = doReq(t, h, http.MethodGet, prefix, nil, imp) + if rr.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want 200", prefix, rr.Code) + } + body := rr.Body.String() + for _, want := range []string{"Operator is logged in as you", "--accent:#16a34a", "border-bottom:1px solid #16a34a"} { + if !strings.Contains(body, want) { + t.Errorf("impersonated overview missing %q", want) + } + } +} + // TestOverviewUserNameEmphasis keeps the current user's name in the overview // heading visibly heavier than the surrounding heading text. func TestOverviewUserNameEmphasis(t *testing.T) { diff --git a/src/internal/panel/templates/login.html b/src/internal/panel/templates/login.html index 61a221b..53f4a47 100644 --- a/src/internal/panel/templates/login.html +++ b/src/internal/panel/templates/login.html @@ -19,10 +19,10 @@ } *{box-sizing:border-box} body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column;align-items:center;padding:8vh 1rem 0;margin:0;min-height:calc(100vh - 8vh)} - .card{background:var(--card);border:3px solid var(--border);padding:1.6rem;border-radius:12px;width:320px} + .card{background:var(--card);border:1px solid var(--border);padding:1.6rem;border-radius:12px;width:320px} h1{font-size:1.3rem;margin:0 0 1rem;font-weight:600} label{display:block;margin:.6rem 0 .2rem;font-size:.84rem;color:var(--muted)} - input{width:100%;padding:.5rem;border-radius:8px;border:3px solid var(--border);background:var(--bg);color:var(--text);font-family:inherit} + input{width:100%;padding:.5rem;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-family:inherit} button{margin-top:1.2rem;width:100%;padding:.55rem;border:0;border-radius:8px;background:var(--accent);color:var(--on-accent);cursor:pointer;font-family:inherit;font-size:.92rem} button:hover{filter:brightness(1.08)} .err{background:var(--err-bg);color:var(--err-fg);padding:.5rem;border-radius:8px;margin-top:1rem;font-size:.84rem} diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index e2893e2..655fa0a 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -33,7 +33,7 @@ body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background .top h1{margin:0;font-size:1.25rem;font-weight:600} .user-name{font-weight:800;letter-spacing:.01em} .top .actions{display:flex;gap:.45rem} - .card{background:var(--card);border:3px solid var(--border);border-radius:10px;padding:.8rem 1rem;margin-bottom:.8rem} + .card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:.8rem 1rem;margin-bottom:.8rem} .card h2{margin:0 0 .55rem;font-size:.9rem;color:var(--head);font-weight:600} .grid{display:grid;grid-template-columns:1fr;gap:.8rem} @media (min-width:860px){.grid{grid-template-columns:1fr 1fr}} @@ -50,7 +50,7 @@ code.copyable.copied{outline:1px solid var(--ok);outline-offset:2px;color:var(-- .up{background:var(--ok-bg);color:var(--ok-fg)} .down{background:var(--err-bg);color:var(--err-fg)} .muted{color:var(--muted);font-size:.8rem} - .btn{display:inline-block;padding:.35rem .75rem;border-radius:7px;border:3px solid var(--border);cursor:pointer;font-size:.82rem;background:var(--card);color:var(--text);font-family:inherit} + .btn{display:inline-block;padding:.35rem .75rem;border-radius:7px;border:1px solid var(--border);cursor:pointer;font-size:.82rem;background:var(--card);color:var(--text);font-family:inherit} .btn[hidden]{display:none} .btn:hover{filter:brightness(1.08)} .btn.pending{background:var(--err-bg);color:var(--err-fg);border-color:var(--danger);cursor:wait} @@ -63,8 +63,8 @@ code.copyable.copied{outline:1px solid var(--ok);outline-offset:2px;color:var(-- .btnrow{display:flex;flex-wrap:wrap;gap:.45rem;margin-bottom:.55rem} .btnrow:last-child{margin-bottom:0} form.inline{display:inline} - input[type=text],input[type=password]{padding:.32rem .5rem;border-radius:7px;border:3px solid var(--border);background:var(--bg);color:var(--text);font-family:inherit;font-size:.85rem} - textarea{width:100%;box-sizing:border-box;padding:.4rem .55rem;border-radius:7px;border:3px solid var(--border);background:var(--bg);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8rem;line-height:1.45;resize:vertical;margin-bottom:.55rem} + input[type=text],input[type=password]{padding:.32rem .5rem;border-radius:7px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-family:inherit;font-size:.85rem} + textarea{width:100%;box-sizing:border-box;padding:.4rem .55rem;border-radius:7px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8rem;line-height:1.45;resize:vertical;margin-bottom:.55rem} input.bad{border-color:var(--danger);outline:1px solid var(--danger)} .frow{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.4rem} .frow label{flex:0 0 4.2rem} @@ -88,7 +88,7 @@ a{color:var(--accent);text-decoration:none} .help.open .tip{display:block} .bandwidth-line{margin:.15rem 0 .6rem;font-size:.8rem} .qgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:.55rem;margin:.15rem 0 .7rem} - .qitem{background:var(--bg);border:3px solid var(--border);border-radius:8px;padding:.45rem .55rem;text-align:center;min-width:0} + .qitem{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:.45rem .55rem;text-align:center;min-width:0} .qlabel{display:block;color:var(--muted);font-size:.72rem;letter-spacing:.02em;margin-bottom:.2rem} .qval{display:block;font-size:.98rem;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap} .bandwidth-quota{margin:.15rem 0 .7rem} @@ -109,7 +109,7 @@ footer a{color:var(--muted);text-decoration:underline} .toast.hide{opacity:0} .overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:99;padding:1rem} .overlay.show{display:flex} - .modal{background:var(--card);border:3px solid var(--border);border-radius:12px;padding:1.2rem 1.3rem;width:100%;max-width:420px;box-shadow:var(--shadow)} + .modal{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.2rem 1.3rem;width:100%;max-width:420px;box-shadow:var(--shadow)} .imglist{display:grid;grid-template-columns:repeat(auto-fill,minmax(118px,1fr));gap:.6rem;margin:.45rem 0 .9rem} .imgopt{position:relative;display:flex;flex-direction:column;align-items:center;gap:.5rem;border:1px solid var(--border);border-radius:12px;padding:.85rem .6rem .7rem;cursor:pointer;text-align:center;transition:border-color .15s,box-shadow .15s,background .15s,transform .12s} .imgopt:hover{border-color:color-mix(in srgb,var(--accent) 45%,var(--border));box-shadow:0 2px 10px var(--accent-glow)} @@ -175,7 +175,7 @@ footer a{color:var(--muted);text-decoration:underline} .reinstall-overlay.show{display:flex} .reinstall-box{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.5rem 1.8rem;text-align:center;box-shadow:var(--shadow);max-width:420px} .reinstall-box .msg{color:var(--text);font-size:.92rem;margin-top:.6rem} -.spinner{width:2rem;height:2rem;border:3px solid var(--border);border-top-color:var(--danger);border-radius:50%;margin:0 auto;animation:spin 1s linear infinite} +.spinner{width:2rem;height:2rem;border:1px solid var(--border);border-top-color:var(--danger);border-radius:50%;margin:0 auto;animation:spin 1s linear infinite} @keyframes spin{to{transform:rotate(360deg)}} {{if .ThemeColor}} @@ -203,7 +203,7 @@ footer a{color:var(--muted);text-decoration:underline} --code-bg:color-mix(in srgb, {{.ThemeColor}} 10%, #14161a); } } -.top{border-bottom:3px solid {{.ThemeColor}};padding-bottom:.7rem} +.top{border-bottom:1px solid {{.ThemeColor}};padding-bottom:.7rem} .top h1{color:{{.ThemeColor}}} {{end}}