feat: session-cookie login/logout + three-tier roles (superadmin/admin/viewer) with read-only UI + remove pink theme

- cookie-based auth (/login /logout) replacing Basic Auth for UI, enabling logout
- roles: superadmin (account management only), admin (full except accounts), viewer/audit (read-only status+cluster, export logs)
- readonly accounts hide edit buttons (added remote node, group ops, canvas layout/save/import, cluster manage, install) instead of greying them
- auditors see status+cluster only; ordinary admins lose the accounts nav; last-admin guard covers superadmin
- remove pink theme entirely (switcher, [data-theme=pink], leftover localStorage), keep white/blue
This commit is contained in:
2026-08-20 09:08:07 +08:00
parent b39bd427fa
commit f29ec81e4a
27 changed files with 1195 additions and 724 deletions

View File

@ -142,14 +142,15 @@ type Forward struct {
Disabled bool `json:"disabled,omitempty"`
}
// User is an authenticated account. Role gates UI/API access (admin = full,
// viewer = read-only + exports, for auditors). System users are synced from
// the -user/-password flags and are read-only in the account-management UI.
// User is an authenticated account. Role gates UI/API access (superadmin =
// full + account management, admin = full except account management, viewer =
// read-only + exports, for auditors). System users are synced from the
// -user/-password flags and are read-only in the account-management UI.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"` // never serialized to clients
Role string `json:"role"` // "admin" | "viewer"
Role string `json:"role"` // "admin" | "viewer" | "superadmin"
Enabled bool `json:"enabled"`
System bool `json:"system"` // true = flag-synced, UI read-only
CreatedAt int64 `json:"createdAt"`
@ -229,7 +230,7 @@ CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin', -- 'admin' | 'viewer'
role TEXT NOT NULL DEFAULT 'admin', -- 'admin' | 'viewer' | 'superadmin'
enabled INTEGER NOT NULL DEFAULT 1,
system INTEGER NOT NULL DEFAULT 0, -- 1 = synced from -user/-password flags, UI read-only
created_at INTEGER NOT NULL DEFAULT 0,
@ -773,7 +774,7 @@ func (s *Store) CreateUser(username, plainPassword, role string) (User, error) {
if username == "" || plainPassword == "" {
return User{}, ErrInvalid
}
if role != "admin" && role != "viewer" {
if role != "admin" && role != "viewer" && role != "superadmin" {
return User{}, ErrInvalid
}
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
@ -802,7 +803,7 @@ func (s *Store) UpdateUser(id int64, role string, enabled bool, plainPassword st
if !ok {
return ErrNotFound
}
if role != "admin" && role != "viewer" {
if role != "admin" && role != "viewer" && role != "superadmin" {
return ErrInvalid
}
if u.System && plainPassword != "" {
@ -840,10 +841,11 @@ func (s *Store) DeleteUser(id int64) error {
return err
}
// CountAdmins returns the count of enabled admin users (for the last-admin guard).
// CountAdmins returns the count of enabled admin/superadmin users (for the
// last-admin guard; both roles can manage others and must never be wiped out).
func (s *Store) CountAdmins() (int, error) {
var n int
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin' AND enabled = 1").Scan(&n)
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role IN ('admin','superadmin') AND enabled = 1").Scan(&n)
return n, err
}