diff --git a/src/internal/admin/handlers.go b/src/internal/admin/handlers.go index 671da3a..95d4b75 100644 --- a/src/internal/admin/handlers.go +++ b/src/internal/admin/handlers.go @@ -366,7 +366,11 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), 400) return } - name := r.FormValue("name") + name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) + if err := s.mgr.ValidateAddName(name, true); err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } cpu, err := mgr.ParseCPU(r.FormValue("cpu")) if err != nil { s.redirect(w, r, s.p(""), "error: "+err.Error()) @@ -393,7 +397,7 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) { if ipv6 == "auto" { ipv6 = "" } - res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6}) + res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6, AllowChild: true}) if err != nil { s.redirect(w, r, s.p(""), "error: "+err.Error()) return @@ -402,9 +406,13 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) { // The password is always auto-generated and shown once. The panel address // is taken from the request's own Host (see panelURL), so it matches // whatever origin the operator actually used to reach the admin panel. - cred := "user: " + res.User.Name + - "\npassword: " + res.Password + - "\npanel: " + s.panelURL(r, "/"+s.cfg.Panel.URLPath) + cred := "user: " + res.User.Name + if res.Password != "" { + cred += "\npassword: " + res.Password + } else { + cred += "\npassword: same as the existing user-group password" + } + cred += "\npanel: " + s.panelURL(r, "/"+s.cfg.Panel.URLPath) // Carry the username as flash data so the modal can offer "log in as". s.redirectModalData(w, r, s.p(""), cred, res.User.Name) } @@ -499,7 +507,7 @@ func (s *Server) handleUserColor(w http.ResponseWriter, r *http.Request) { s.redirect(w, r, s.p(""), "error: user not found") return } - if err := s.db.UpdateUserColor(u.ID, color); err != nil { + if err := s.mgr.SetGroupColor(u.Name, color); err != nil { s.redirect(w, r, s.p(""), "error: "+err.Error()) return } diff --git a/src/internal/admin/templates/admin_overview.html b/src/internal/admin/templates/admin_overview.html index c4dcc57..1920883 100644 --- a/src/internal/admin/templates/admin_overview.html +++ b/src/internal/admin/templates/admin_overview.html @@ -205,7 +205,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }

{{if eq .Lang "zh"}}创建用户{{else}}Create user{{end}}

-
{{if eq .Lang "zh"}}自动转为小写{{else}}Folded to lowercase{{end}}
+
{{if eq .Lang "zh"}}自动转为小写{{else}}Folded to lowercase{{end}}
= 1) or a decimal 0.1..0.9 (a fraction of one core){{end}}">
@@ -580,7 +580,7 @@ function del(name){ var cpu = document.getElementById('cfCpu'), mem = document.getElementById('cfMem'), disk = document.getElementById('cfDisk'), bandwidth = document.getElementById('cfBandwidth'), btn = document.getElementById('cfBtn'); var nameIn = document.getElementById('cfName'), nameHint = document.getElementById('cfNameHint'); var existing = { {{range .Users}}"{{.Name}}":1, {{end}}}; - var MSG = {{if eq .Lang "zh"}}{ok:'用户名可用', dup:'该用户名已存在', invalid:'小写字母开头和结尾,仅含小写字母、数字和连字符', hint:'自动转为小写'}{{else}}{ok:'Username available', dup:'Username already exists', invalid:'start/end with a lowercase letter; lowercase letters, digits, hyphens only', hint:'Folded to lowercase'}{{end}}; + var MSG = {{if eq .Lang "zh"}}{ok:'用户名可用', group:'将创建用户组:', groupAfter:'(已有 ', groupEnd:' 个账号,创建后 ', accounts:' 个)', dup:'该用户名已存在', invalid:'主用户必须以字母结尾;子用户格式为 主用户-数字,且不允许嵌套', hint:'自动转为小写'}{{else}}{ok:'Username available', group:'User group to create: ', groupAfter:' (', groupEnd:' accounts, ', accounts:' after creation)', dup:'Username already exists', invalid:'parent must end with a letter; child format is parent-number, nesting is not allowed', hint:'Folded to lowercase'}{{end}}; function nameStatus(){ var raw = nameIn.value; // Fold any uppercase to lowercase as the user types. @@ -589,18 +589,27 @@ function del(name){ nameIn.setCustomValidity(''); nameHint.classList.remove('hint-ok', 'hint-bad'); if (!low) { nameHint.textContent = MSG.hint; nameIn.classList.remove('bad'); return; } - var okRe = /^[a-z]([a-z0-9-]*[a-z])?$/.test(low); - if (!okRe) { - nameIn.classList.add('bad'); - nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.invalid; - nameIn.setCustomValidity(MSG.invalid); +var child = low.match(/^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/); + var parent = child ? child[1] : ''; + var okRe = /^[a-z]([a-z0-9-]*[a-z])?$/.test(low) || !!child; + if (!okRe || (child && /^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/.test(parent))) { + nameIn.classList.add('bad'); + nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.invalid; + nameIn.setCustomValidity(MSG.invalid); } else if (existing[low]) { - nameIn.classList.add('bad'); - nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.dup; - nameIn.setCustomValidity(MSG.dup); + nameIn.classList.add('bad'); + nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.dup; + nameIn.setCustomValidity(MSG.dup); } else { - nameIn.classList.remove('bad'); - nameHint.classList.add('hint-ok'); nameHint.textContent = MSG.ok; + nameIn.classList.remove('bad'); + var count = 0; + var group = child ? parent : low; + Object.keys(existing).forEach(function(n){ + var m = n.match(/^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/); + if (n === group || (m && m[1] === group)) count++; + }); + nameHint.classList.add('hint-ok'); + nameHint.textContent = child ? MSG.group + group + MSG.groupAfter + count + MSG.groupEnd + (count + 1) + MSG.accounts : (count ? MSG.group + group + MSG.groupAfter + count + MSG.groupEnd + (count + 1) + MSG.accounts : MSG.ok); } } if (nameIn) nameIn.addEventListener('input', nameStatus); diff --git a/src/internal/db/group.go b/src/internal/db/group.go new file mode 100644 index 0000000..ce8806d --- /dev/null +++ b/src/internal/db/group.go @@ -0,0 +1,38 @@ +package db + +func (d *DB) UpdateUsersPasswordAndDeleteSessions(ids []int64, passHash, keepToken string) error { + tx, err := d.sql.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, id := range ids { + if _, err := tx.Exec(`UPDATE users SET pass_hash=? WHERE id=?`, passHash, id); err != nil { + return err + } + if keepToken == "" { + if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=?`, id); err != nil { + return err + } + continue + } + if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=? AND token<>?`, id, hashToken(keepToken)); err != nil { + return err + } + } + return tx.Commit() +} + +func (d *DB) UpdateUsersColor(ids []int64, color string) error { + tx, err := d.sql.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, id := range ids { + if _, err := tx.Exec(`UPDATE users SET color=? WHERE id=?`, color, id); err != nil { + return err + } + } + return tx.Commit() +} diff --git a/src/internal/db/group_test.go b/src/internal/db/group_test.go new file mode 100644 index 0000000..1d7a633 --- /dev/null +++ b/src/internal/db/group_test.go @@ -0,0 +1,81 @@ +package db + +import ( + "testing" + + "vpsmgr/internal/pw" +) + +func TestUpdateUsersColorAtomically(t *testing.T) { + d, err := Open(t.TempDir() + "/group.db") + if err != nil { + t.Fatal(err) + } + defer d.Close() + first, err := d.CreateUser("alice", "old-a", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10) + if err != nil { + t.Fatal(err) + } + second, err := d.CreateUser("alice-1", "old-b", "10.42.0.3", 2, 30002, 10100, 1, 1024, 10) + if err != nil { + t.Fatal(err) + } + if err := d.UpdateUsersColor([]int64{first.ID, second.ID}, "#16a34a"); err != nil { + t.Fatal(err) + } + for _, id := range []int64{first.ID, second.ID} { + u, err := d.GetUserByID(id) + if err != nil { + t.Fatal(err) + } + if u.Color != "#16a34a" { + t.Errorf("user %d color = %q, want #16a34a", id, u.Color) + } + } +} + +func TestUpdateUsersPasswordAndDeleteSessions(t *testing.T) { + d, err := Open(t.TempDir() + "/group.db") + if err != nil { + t.Fatal(err) + } + defer d.Close() + first, err := d.CreateUser("alice", "old-a", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10) + if err != nil { + t.Fatal(err) + } + second, err := d.CreateUser("alice-1", "old-b", "10.42.0.3", 2, 30002, 10100, 1, 1024, 10) + if err != nil { + t.Fatal(err) + } + keep, err := d.CreateSession(first.ID, 1) + if err != nil { + t.Fatal(err) + } + old, err := d.CreateSession(second.ID, 1) + if err != nil { + t.Fatal(err) + } + hash, err := pw.Hash("new-password") + if err != nil { + t.Fatal(err) + } + if err := d.UpdateUsersPasswordAndDeleteSessions([]int64{first.ID, second.ID}, hash, keep.Token); err != nil { + t.Fatal(err) + } + for _, id := range []int64{first.ID, second.ID} { + u, err := d.GetUserByID(id) + if err != nil { + t.Fatal(err) + } + if !pw.Verify(u.PassHash, "new-password") { + t.Errorf("user %d password was not updated", id) + } + } + if _, _, err := d.SessionWithFlag(keep.Token); err != nil { + t.Fatalf("kept session rejected: %v", err) + } + if _, _, err := d.SessionWithFlag(old.Token); err == nil { + t.Fatal("old sibling session remained valid") + } +} diff --git a/src/internal/mgr/group.go b/src/internal/mgr/group.go new file mode 100644 index 0000000..87112c7 --- /dev/null +++ b/src/internal/mgr/group.go @@ -0,0 +1,95 @@ +package mgr + +import ( + "errors" + "regexp" + "strings" + + "vpsmgr/internal/db" +) + +var childNameRe = regexp.MustCompile(`^([a-z][a-z0-9-]*[a-z])-([0-9]+)$`) +var numericSuffixRe = regexp.MustCompile(`-[0-9]+$`) + +type UserGroup struct { + Name string + Parent string + Child bool +} + +func ParseUserGroup(name string) UserGroup { + name = strings.ToLower(name) + m := childNameRe.FindStringSubmatch(name) + if len(m) == 0 || childNameRe.MatchString(m[1]) { + return UserGroup{Name: name} + } + return UserGroup{Name: name, Parent: m[1], Child: true} +} + +func UserGroupName(name string) string { + g := ParseUserGroup(name) + if g.Child { + return g.Parent + } + return g.Name +} + +func (m *Manager) ValidateAddName(name string, allowChild bool) error { + name = strings.ToLower(name) + g := ParseUserGroup(name) + if !g.Child { + if numericSuffixRe.MatchString(name) { + return errors.New("invalid child username: text before -number must start and end with a letter") + } + return ValidateName(name) + } + if !allowChild { + return errors.New("child users can only be created from the admin panel") + } + if len(name) > 31 { + return errors.New("invalid child username: max 31 characters") + } + return nil +} + +func (m *Manager) UsersInGroup(name string) ([]*db.User, error) { + group := UserGroupName(name) + users, err := m.db.ListUsers() + if err != nil { + return nil, err + } + out := make([]*db.User, 0) + for _, u := range users { + if UserGroupName(u.Name) == group { + out = append(out, u) + } + } + return out, nil +} + +func (m *Manager) SetGroupColor(name, color string) error { + m.opMu.Lock() + defer m.opMu.Unlock() + users, err := m.UsersInGroup(name) + if err != nil { + return err + } + ids := make([]int64, 0, len(users)) + for _, u := range users { + ids = append(ids, u.ID) + } + return m.db.UpdateUsersColor(ids, color) +} + +func (m *Manager) inheritGroupColorLocked(name string, excludeID int64) error { + users, err := m.UsersInGroup(name) + if err != nil { + return err + } + for _, u := range users { + if u.ID != excludeID && u.Color != "" { + return m.db.UpdateUsersColor([]int64{excludeID}, u.Color) + } + } + return nil +} diff --git a/src/internal/mgr/group_test.go b/src/internal/mgr/group_test.go new file mode 100644 index 0000000..2318879 --- /dev/null +++ b/src/internal/mgr/group_test.go @@ -0,0 +1,81 @@ +package mgr + +import ( + "testing" + + "vpsmgr/internal/db" +) + +func TestParseUserGroup(t *testing.T) { + tests := []struct { + name string + parent string + child bool + }{ + {name: "alice", parent: "", child: false}, + {name: "alice-1", parent: "alice", child: true}, + {name: "alice-12", parent: "alice", child: true}, + {name: "my-box-3", parent: "my-box", child: true}, + {name: "alice-1-2", parent: "", child: false}, + {name: "alice1", parent: "", child: false}, + } + for _, tt := range tests { + got := ParseUserGroup(tt.name) + if got.Parent != tt.parent || got.Child != tt.child { + t.Fatalf("ParseUserGroup(%q) = %+v, want parent=%q child=%v", tt.name, got, tt.parent, tt.child) + } + } +} + +func TestUserGroupName(t *testing.T) { + if got := UserGroupName("alice-1"); got != "alice" { + t.Fatalf("UserGroupName(alice-1) = %q, want alice", got) + } + if got := UserGroupName("alice"); got != "alice" { + t.Fatalf("UserGroupName(alice) = %q, want alice", got) + } +} + +func TestValidateAddName(t *testing.T) { + m := &Manager{} + for _, name := range []string{"alice", "alice-1", "alice-99"} { + if err := m.ValidateAddName(name, true); err != nil { + t.Errorf("ValidateAddName(%q) = %v, want nil", name, err) + } + } + for _, name := range []string{"alice-1-2", "alice--1", "-1"} { + if err := m.ValidateAddName(name, true); err == nil { + t.Errorf("ValidateAddName(%q) = nil, want error", name) + } + } + if err := m.ValidateAddName("alice-1", false); err == nil { + t.Error("ValidateAddName child with allowChild=false = nil, want error") + } +} + +func TestUsersInGroup(t *testing.T) { + d := testDB(t) + for i, name := range []string{"alice", "alice-1", "alice-2", "bob"} { + if _, err := d.CreateUser(name, "hash", "10.42.0."+string(rune('2'+i)), i+1, 30001+i, 10000+i*100, 1, 1024, 10); err != nil { + t.Fatal(err) + } + } + m := &Manager{db: d} + users, err := m.UsersInGroup("alice-1") + if err != nil { + t.Fatal(err) + } + if len(users) != 3 { + t.Fatalf("UsersInGroup(alice-1) returned %d users, want 3", len(users)) + } +} + +func testDB(t *testing.T) *db.DB { + t.Helper() + d, err := db.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return d +} diff --git a/src/internal/mgr/mgr.go b/src/internal/mgr/mgr.go index 8dabc90..d8f503f 100644 --- a/src/internal/mgr/mgr.go +++ b/src/internal/mgr/mgr.go @@ -316,9 +316,10 @@ systemctl restart ssh` } type AddOptions struct { - CPU int - MemMB int - DiskGB int + CPU int + MemMB int + DiskGB int + AllowChild bool // BandwidthGB is the monthly bandwidth quota in GiB (0 = unlimited). BandwidthGB int // IPv6Addr is the pool-mode address to assign ("" = auto-pick the first @@ -335,7 +336,7 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) { // Usernames are case-insensitive: fold to lowercase before validating and // checking for duplicates, so "Alice" and "alice" are the same user. name = strings.ToLower(name) - if err := ValidateName(name); err != nil { + if err := m.ValidateAddName(name, opt.AllowChild); err != nil { return nil, err } if err := ValidateCPU(opt.CPU); err != nil { @@ -350,14 +351,27 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) { if _, err := m.db.GetUserByName(name); err == nil { return nil, errors.New("user already exists: " + name) } - // Passwords are always generated ([a-zA-Z0-9]) — no user-supplied password - // is ever accepted, so nothing untrusted reaches the provisioning shell - // scripts. + // The panel password is shared by a user group. A new group gets a fresh + // password; a new member of an existing group reuses its hash. The generated + // plaintext is still used as the container root password, but is not exposed + // when the panel password was inherited. pass := pw.Generate(20) hash, err := pw.Hash(pass) if err != nil { return nil, err } + inheritedPanelPassword := false + if groupUsers, err := m.UsersInGroup(name); err == nil { + for _, member := range groupUsers { + if member.PassHash != "" { + hash = member.PassHash + inheritedPanelPassword = true + break + } + } + } else { + return nil, err + } // IPv4 slot: a random unused idx in 1..MaxUsers (the /24 host range). The // port block is allocated independently from the configured user-port // ranges (allocUserPortBlock) — the two are no longer coupled. @@ -527,7 +541,12 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) { m.db.UpdateUserStatus(u.ID, db.StatusFailed) return nil, fmt.Errorf("db: mark user ready: %w", err) } - return m.ResultFor(u, pass), nil + _ = m.inheritGroupColorLocked(u.Name, u.ID) + result := m.ResultFor(u, pass) + if inheritedPanelPassword { + result.Password = "" + } + return result, nil } // checkIPv6BlockCollision refuses a new container if its deterministic /112 @@ -1168,6 +1187,8 @@ func snapshotCreateTime(s lx.SnapshotInfo) time.Time { // preserve (empty means none, so every session is dropped). Container root // password is managed separately via ResetRootPassword. func (m *Manager) ChangePanelPassword(name, pass, keepToken string) error { + m.opMu.Lock() + defer m.opMu.Unlock() u, err := m.db.GetUserByName(name) if err != nil { return err @@ -1176,10 +1197,15 @@ func (m *Manager) ChangePanelPassword(name, pass, keepToken string) error { if err != nil { return err } - if err := m.db.UpdatePassword(u.ID, hash); err != nil { + members, err := m.UsersInGroup(u.Name) + if err != nil { return err } - return m.db.DeleteSessionsForUserExcept(u.ID, keepToken) + ids := make([]int64, 0, len(members)) + for _, member := range members { + ids = append(ids, member.ID) + } + return m.db.UpdateUsersPasswordAndDeleteSessions(ids, hash, keepToken) } // ResetPanelPassword sets the panel login password to a new random 20-char diff --git a/src/internal/panel/handlers.go b/src/internal/panel/handlers.go index 4e3af6b..e91405e 100644 --- a/src/internal/panel/handlers.go +++ b/src/internal/panel/handlers.go @@ -190,6 +190,46 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { s.redirect(w, r, s.p("/login"), "") } +func (s *Server) handleSwitchUser(w http.ResponseWriter, r *http.Request) { + current := s.currentUser(r) + targetName := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) + members, err := s.mgr.UsersInGroup(current.Name) + if err != nil { + s.redirect(w, r, s.p("/"), "error: unable to switch account") + return + } + var target *db.User + for _, member := range members { + if member.Name == targetName { + target = member + break + } + } + if target == nil { + s.redirect(w, r, s.p("/"), "error: account is not in this user group") + return + } + oldToken := "" + if cookie, err := r.Cookie("vpsmgr_session"); err == nil { + oldToken = cookie.Value + } + sess, err := s.db.CreateSession(target.ID, s.cfg.Panel.SessionDays) + if err != nil { + s.redirect(w, r, s.p("/"), "error: unable to switch account") + return + } + if oldToken != "" { + if err := s.db.DeleteSession(oldToken); err != nil { + s.db.DeleteSession(sess.Token) + s.redirect(w, r, s.p("/"), "error: unable to switch account") + return + } + } + s.setSessionCookie(w, sess.Token) + _ = s.db.AddAuditLog(current.Name, "session.switch."+target.Name) + http.Redirect(w, r, s.p("/"), http.StatusFound) +} + func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { u := s.currentUser(r) d := s.buildData(u, "", "") diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index feb6c55..9668fa1 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -5,6 +5,7 @@ import ( "html/template" "net" "net/http" + "sort" "strings" "sync" "time" @@ -129,6 +130,9 @@ type sshKeyRow struct { type pageData struct { Title string User *db.User + GroupUsers []*db.User + GroupIndex int + GroupCount int State string IP string SSHPort int @@ -179,6 +183,7 @@ func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/login", s.handleLogin) mux.HandleFunc("/logout", s.requireAuth(s.requirePost(s.handleLogout))) + mux.HandleFunc("/switch-user", s.requireAuth(s.requirePost(s.handleSwitchUser))) mux.HandleFunc("/", s.requireAuth(s.handleOverview)) mux.HandleFunc("/power", s.requireAuth(s.requirePost(s.handlePower))) mux.HandleFunc("/reinstall", s.requireAuth(s.requirePost(s.handleReinstall))) @@ -301,9 +306,21 @@ func (s *Server) redirectModal(w http.ResponseWriter, r *http.Request, path, msg } func (s *Server) buildData(u *db.User, msg, errMsg string) pageData { + groupUsers, _ := s.mgr.UsersInGroup(u.Name) + sort.Slice(groupUsers, func(i, j int) bool { return groupUsers[i].Name < groupUsers[j].Name }) + groupIndex := 0 + for i, member := range groupUsers { + if member.ID == u.ID { + groupIndex = i + 1 + break + } + } d := pageData{ Title: "VPS Manager", User: u, + GroupUsers: groupUsers, + GroupIndex: groupIndex, + GroupCount: len(groupUsers), ThemeColor: u.Color, PublicIP: s.cfg.DisplayIP(), Prefix: s.prefix(), diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index bf667b5..7bf8895 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -74,6 +74,8 @@ label{font-size:.84rem;color:var(--muted)} .impbanner b{color:var(--violet-fg)} .impbanner .impback{margin-left:auto} .impbanner a{color:var(--accent);text-decoration:underline;font-weight:600} +.groupbanner{background:#fef3c7;color:#92400e;border:2px solid #f59e0b;padding:.65rem .85rem;border-radius:8px;margin-bottom:.8rem;font-size:.92rem;font-weight:700} +@media (prefers-color-scheme: dark){.groupbanner{background:#3b2f10;color:#fcd34d;border-color:#b45309}} a{color:var(--accent);text-decoration:none} .domtable{width:100%;border-collapse:collapse;font-size:.85rem;margin:.2rem 0 .6rem} .domtable th,.domtable td{text-align:left;padding:.3rem .4rem;border-bottom:1px solid var(--border);vertical-align:middle} @@ -223,12 +225,18 @@ footer a{color:var(--muted);text-decoration:underline}

VPS Manager

+{{if gt (len .GroupUsers) 1}} + +{{end}} {{if eq .Lang "zh"}}English{{else}}中文{{end}}
{{if .Err}}
{{.Err}}
{{end}} +{{if gt .GroupCount 1}} +
⚠ {{if eq .Lang "zh"}}您的用户组内有多个容器,当前容器序号为 {{.GroupIndex}} / {{.GroupCount}}({{.User.Name}})。请确认当前容器后再执行重装或快照操作。{{else}}Your user group has multiple containers. Current container: {{.GroupIndex}} / {{.GroupCount}} ({{.User.Name}}). Confirm the current container before reinstalling or using snapshots.{{end}}
+{{end}} {{if .Impersonated}}
{{if eq .Lang "zh"}}管理员代客登录中{{else}}Operator is logged in as you{{end}} @@ -442,6 +450,7 @@ footer a{color:var(--muted);text-decoration:underline}