fix(panel): theme color only on admin impersonation; thin borders back to 1px

- Per-user accent color now tints the user panel only for operator
  impersonation sessions (handleOverview clears ThemeColor otherwise);
  users logging in with their own password see the default theme
- Restore 1px borders on user-panel cards, buttons, inputs, quick stats,
  modals and spinner, and on the login card/input (was 3px)
- Update docs and tests; add HTTP-level test covering both session kinds

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-06 04:52:37 +00:00
1 parent 01869464cb
commit b46bd0a901
7 files changed
+77 -14

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -10,7 +10,7 @@
- 管理员面板:用户、配额、域名、IPv6 地址池、SSH 公钥与审计日志;支持"以用户身份登录"接入用户面板
- 用户面板:电源控制、自助重装 Debian 13/AlmaLinux 9/openSUSE Leap 16/Arch Linux、域名与 SSH 密钥管理、加密便签(可导出)、时光机(检查点快照,回滚即丢弃此后的检查点)
- 管理员可为每个用户分配专属颜色(在用户列表中点击用户名):该用户的「登录面板」按钮与其用户面板背景会采用此颜色,方便代客登录时区分不同用户
- 管理员可为每个用户分配专属颜色(在用户列表中点击用户名):该用户的「登录面板」按钮采用此颜色,管理员代客登录时其用户面板也以此颜色渲染,方便区分不同用户;用户自行登录始终显示默认主题
- CPU、内存、磁盘支持超售;配额修改实时生效,无需重启容器
- 容器网络严格隔离;IPv6 前缀模式下每个容器获得独立 `/112`,可继续划分子网
- 流量按上下行合计,超额后双向限速至 1 Mbps
+3 -1
View File
@@ -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+<user>`.
- **Snapshots ("时光机" / time machine)**: each user can keep up to
+6
View File
@@ -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)
}
+56 -1
View File
@@ -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) {
+2 -2
View File
@@ -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}
+8 -8
View File
@@ -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)}}
</style>
{{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}}}
</style>
{{end}}