@
hub v0.54.0: change operator login password from the Configuration UI Adds a "Login password" card on /configuration. The password was previously settable only via the hub-config ConfigMap (auth.password_hash) + redeploy. - store: hub_settings key operator_password_hash + Get/SetOperatorPasswordHash - server: passwordHash field -> configPasswordHash (seed); new effectivePasswordHash() (DB override wins, else seed) is now the single source for the CSRF gate, RequireAuth, and handleLogin - POST /configuration/password (handleChangePassword): requires current password, 8-72 byte new + confirm, bcrypt cost 10, persists DB override; existing sessions kept valid; ConfigMap stays the break-glass reset path - UI: current/new/confirm form + inline mismatch pre-check + 6 flashes - tests + red-proofs: override precedence, happy-path via handleLogin, wrong-current rejection, mismatch/too-short/no-op, template render - docs: CHANGELOG, README (auth+config), REUSE, REPORT Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbMm4T7Ayzs1unB9pN6Uqd @
This commit is contained in:
@@ -1347,6 +1347,22 @@ const (
|
||||
settingArtifactMinAgent = "artifact_min_agent"
|
||||
)
|
||||
|
||||
// settingOperatorPasswordHash is the hub_settings key for the operator login password bcrypt hash,
|
||||
// set via the Configuration UI (v0.54.0). When present it OVERRIDES the config/env seed
|
||||
// (auth.password_hash in hub.yaml) — the same DB-override-wins precedence as the controller-version
|
||||
// floor. The ConfigMap value stays the break-glass fallback: clear this row (or edit the manifest +
|
||||
// redeploy) to reset a lost password.
|
||||
const settingOperatorPasswordHash = "operator_password_hash"
|
||||
|
||||
// GetOperatorPasswordHash returns the UI-set operator password bcrypt hash, or "" when none has been
|
||||
// set (the config/env seed is then authoritative).
|
||||
func (s *Store) GetOperatorPasswordHash() string { return s.getSetting(settingOperatorPasswordHash) }
|
||||
|
||||
// SetOperatorPasswordHash persists a new operator password bcrypt hash set via the Configuration UI.
|
||||
func (s *Store) SetOperatorPasswordHash(hash string) error {
|
||||
return s.setSetting(settingOperatorPasswordHash, hash)
|
||||
}
|
||||
|
||||
// getSetting reads a single hub_settings value ("" if the row is absent).
|
||||
func (s *Store) getSetting(key string) string {
|
||||
var v string
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// serverWithPassword builds a hub server whose CONFIG seed (hub.yaml auth.password_hash) is the
|
||||
// bcrypt hash of plaintext — mirroring a freshly-deployed hub with no UI override yet.
|
||||
func serverWithPassword(t *testing.T, plaintext string) (*Server, *store.Store) {
|
||||
t.Helper()
|
||||
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("store.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("seed hash: %v", err)
|
||||
}
|
||||
s := New(st, string(hash), "", "test", 30*time.Minute, log.New(io.Discard, "", 0))
|
||||
return s, st
|
||||
}
|
||||
|
||||
func postChangePassword(t *testing.T, s *Server, current, next, confirm string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
form := url.Values{
|
||||
"current_password": {current},
|
||||
"new_password": {next},
|
||||
"confirm_password": {confirm},
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/configuration/password", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleChangePassword(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestEffectivePasswordHash_DBOverrideWins locks the precedence the whole feature rests on: a
|
||||
// hub_settings override wins over the config/env seed, and clearing it falls back to the seed.
|
||||
// Companion red-proof: make effectivePasswordHash return s.configPasswordHash unconditionally → the
|
||||
// "DB override wins" assertion fails.
|
||||
func TestEffectivePasswordHash_DBOverrideWins(t *testing.T) {
|
||||
s, st := serverWithPassword(t, "seed-password")
|
||||
|
||||
// No override yet → the seed is authoritative.
|
||||
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("seed-password")) != nil {
|
||||
t.Fatal("with no DB override the config seed must be effective")
|
||||
}
|
||||
|
||||
dbHash, _ := bcrypt.GenerateFromPassword([]byte("db-password"), bcrypt.DefaultCost)
|
||||
if err := st.SetOperatorPasswordHash(string(dbHash)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Override present → it wins; the seed must no longer authenticate.
|
||||
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("db-password")) != nil {
|
||||
t.Error("DB override must win over the config seed")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("seed-password")) == nil {
|
||||
t.Error("the config seed must NOT authenticate once a DB override is set")
|
||||
}
|
||||
|
||||
// Clearing the override → fall back to the seed (the break-glass reset path).
|
||||
if err := st.SetOperatorPasswordHash(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("seed-password")) != nil {
|
||||
t.Error("clearing the DB override must fall back to the config seed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChangePassword_HappyPath: a correct current password + matching confirmation persists a DB
|
||||
// override, the new password authenticates end-to-end (through handleLogin), and the OLD password is
|
||||
// dead. Companion red-proof: drop the SetOperatorPasswordHash call → the new password never takes
|
||||
// effect and the login assertion fails.
|
||||
func TestChangePassword_HappyPath(t *testing.T) {
|
||||
s, st := serverWithPassword(t, "old-password")
|
||||
|
||||
w := postChangePassword(t, s, "old-password", "brand-new-password", "brand-new-password")
|
||||
if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=pw_changed") {
|
||||
t.Fatalf("expected redirect to pw_changed, got %d %q", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// The override is persisted and the new password is effective; the old one is gone.
|
||||
if st.GetOperatorPasswordHash() == "" {
|
||||
t.Fatal("change must persist a hub_settings override")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("brand-new-password")) != nil {
|
||||
t.Error("new password must be effective after change")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("old-password")) == nil {
|
||||
t.Error("old password must stop working after change")
|
||||
}
|
||||
|
||||
// End-to-end: /login now accepts the new password and rejects the old.
|
||||
if code := loginStatus(t, s, "brand-new-password"); code != http.StatusSeeOther {
|
||||
t.Errorf("login with new password: got %d, want 303", code)
|
||||
}
|
||||
if code := loginStatus(t, s, "old-password"); code != http.StatusUnauthorized {
|
||||
t.Errorf("login with old password: got %d, want 401", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChangePassword_WrongCurrentRejected is the security anchor: an attacker on an open session (or a
|
||||
// typo) cannot set a new password without the current one. Companion red-proof: remove the
|
||||
// current-password bcrypt check in handleChangePassword → this test fails (the override gets written).
|
||||
func TestChangePassword_WrongCurrentRejected(t *testing.T) {
|
||||
s, st := serverWithPassword(t, "old-password")
|
||||
|
||||
w := postChangePassword(t, s, "WRONG", "brand-new-password", "brand-new-password")
|
||||
if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=pw_current_wrong") {
|
||||
t.Fatalf("expected redirect to pw_current_wrong, got %d %q", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
if st.GetOperatorPasswordHash() != "" {
|
||||
t.Error("a wrong current password must NOT write an override")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("old-password")) != nil {
|
||||
t.Error("the original password must still be effective after a rejected change")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChangePassword_ValidationRejections: mismatch, too-short, and no-op changes are all refused and
|
||||
// leave the password untouched.
|
||||
func TestChangePassword_ValidationRejections(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, current, next, confirm, wantFlash string
|
||||
}{
|
||||
{"mismatch", "old-password", "brand-new-password", "different-confirm", "pw_mismatch"},
|
||||
{"too_short", "old-password", "short", "short", "pw_too_short"},
|
||||
{"unchanged", "old-password", "old-password", "old-password", "pw_unchanged"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s, st := serverWithPassword(t, "old-password")
|
||||
w := postChangePassword(t, s, c.current, c.next, c.confirm)
|
||||
if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash="+c.wantFlash) {
|
||||
t.Fatalf("expected redirect to %s, got %d %q", c.wantFlash, w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
if st.GetOperatorPasswordHash() != "" {
|
||||
t.Errorf("%s: rejected change must NOT write an override", c.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigurationPage_RendersPasswordCard: the change-password form renders through the production
|
||||
// template with the three fields and the correct POST target.
|
||||
func TestConfigurationPage_RendersPasswordCard(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/configuration", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleConfiguration(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("configuration page: %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{`action="/configuration/password"`, `name="current_password"`, `name="new_password"`, `name="confirm_password"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("configuration page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loginStatus drives handleLogin with a password and returns the HTTP status (303 = accepted,
|
||||
// 401 = rejected).
|
||||
func loginStatus(t *testing.T, s *Server, password string) int {
|
||||
t.Helper()
|
||||
form := url.Values{"password": {password}}
|
||||
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleLogin(w, r)
|
||||
return w.Code
|
||||
}
|
||||
@@ -49,8 +49,12 @@ type hubSession struct {
|
||||
// Server handles the dashboard web UI.
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
passwordHash string
|
||||
apiKey string // report API key — used for controller callbacks
|
||||
// configPasswordHash is the operator login password bcrypt hash SEEDED from hub.yaml
|
||||
// (auth.password_hash) at startup. It is the fallback only — a hub_settings DB override set via
|
||||
// the Configuration UI wins. Never read this field directly for an auth decision; call
|
||||
// effectivePasswordHash().
|
||||
configPasswordHash string
|
||||
apiKey string // report API key — used for controller callbacks
|
||||
version string
|
||||
logger *log.Logger
|
||||
templates *template.Template
|
||||
@@ -100,9 +104,9 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
|
||||
tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
|
||||
|
||||
return &Server{
|
||||
store: store,
|
||||
passwordHash: passwordHash,
|
||||
apiKey: apiKey,
|
||||
store: store,
|
||||
configPasswordHash: passwordHash,
|
||||
apiKey: apiKey,
|
||||
version: version,
|
||||
logger: logger,
|
||||
templates: tmpl,
|
||||
@@ -111,6 +115,18 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
|
||||
}
|
||||
}
|
||||
|
||||
// effectivePasswordHash returns the operator login password bcrypt hash in force: the UI-set DB
|
||||
// override (hub_settings, via the Configuration page) when present, otherwise the config/env seed
|
||||
// (auth.password_hash from hub.yaml). This is the SINGLE source of truth for every auth check — the
|
||||
// DB override wins and the ConfigMap value is the break-glass fallback, mirroring the
|
||||
// controller-version floor's precedence. Empty return = auth disabled (dev/test only).
|
||||
func (s *Server) effectivePasswordHash() string {
|
||||
if h := s.store.GetOperatorPasswordHash(); h != "" {
|
||||
return h
|
||||
}
|
||||
return s.configPasswordHash
|
||||
}
|
||||
|
||||
// CleanupSessions removes expired sessions. Call with: go s.CleanupSessions(ctx).
|
||||
func (s *Server) CleanupSessions(ctx context.Context) {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
@@ -197,7 +213,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// CSRF protection for all state-changing requests (web routes only).
|
||||
// API routes (/api/v1/) are Bearer-token authenticated and exempt.
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
|
||||
if path != "/login" && s.passwordHash != "" {
|
||||
if path != "/login" && s.effectivePasswordHash() != "" {
|
||||
if !s.validateCSRF(r) {
|
||||
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr)
|
||||
http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden)
|
||||
@@ -399,6 +415,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case path == "/configuration/password":
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleChangePassword(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/delete")
|
||||
@@ -472,7 +494,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth if no password configured
|
||||
if s.passwordHash == "" {
|
||||
if s.effectivePasswordHash() == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -496,7 +518,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
|
||||
// Check basic auth (for programmatic/CLI access)
|
||||
_, password, ok := r.BasicAuth()
|
||||
if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
|
||||
if ok && bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte(password)) == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -514,7 +536,8 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
password := r.FormValue("password")
|
||||
if s.passwordHash != "" && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
|
||||
effHash := s.effectivePasswordHash()
|
||||
if effHash != "" && bcrypt.CompareHashAndPassword([]byte(effHash), []byte(password)) == nil {
|
||||
// Generate random session token
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
@@ -786,3 +809,64 @@ func (s *Server) handleConfigurationAction(w http.ResponseWriter, r *http.Reques
|
||||
http.Redirect(w, r, "/configuration", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// minOperatorPasswordLen is the minimum accepted new operator login password length (bytes). A low
|
||||
// floor by design — this is the single operator's own login, not a customer-facing credential — but
|
||||
// it stops fat-finger empties/typos from silently becoming the password. bcrypt caps input at 72
|
||||
// bytes, so that is the hard upper bound.
|
||||
const minOperatorPasswordLen = 8
|
||||
|
||||
// handleChangePassword updates the operator login password from the Configuration page (v0.54.0).
|
||||
// It requires the CURRENT password (verified against the effective hash — DB override → config seed),
|
||||
// a new password of at least minOperatorPasswordLen bytes, and a matching confirmation. On success it
|
||||
// bcrypts the new password (cost 10, matching the ConfigMap seed) and persists it to hub_settings, the
|
||||
// DB override that wins over the hub.yaml seed. The ConfigMap value stays the break-glass fallback:
|
||||
// blank the DB row (or edit the manifest + redeploy) to reset a lost password. Existing sessions are
|
||||
// intentionally left valid — only the /login and Basic-Auth checks consult the new hash. CSRF is
|
||||
// already enforced by ServeHTTP for this POST.
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
current := r.FormValue("current_password")
|
||||
next := r.FormValue("new_password")
|
||||
confirm := r.FormValue("confirm_password")
|
||||
|
||||
// Verify the current password against the effective hash. Empty effective hash (auth disabled)
|
||||
// also blocks the change — there is nothing to authenticate against.
|
||||
if eff := s.effectivePasswordHash(); eff == "" || bcrypt.CompareHashAndPassword([]byte(eff), []byte(current)) != nil {
|
||||
s.logger.Printf("[WARN] Change-password rejected: current password mismatch from %s", r.RemoteAddr)
|
||||
http.Redirect(w, r, "/configuration?flash=pw_current_wrong", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if len(next) < minOperatorPasswordLen {
|
||||
http.Redirect(w, r, "/configuration?flash=pw_too_short", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if len(next) > 72 { // bcrypt hard limit — reject up front for a friendly message
|
||||
http.Redirect(w, r, "/configuration?flash=pw_too_long", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if next != confirm {
|
||||
http.Redirect(w, r, "/configuration?flash=pw_mismatch", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if next == current { // no-op change — keep the flash honest
|
||||
http.Redirect(w, r, "/configuration?flash=pw_unchanged", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Change-password: bcrypt generate failed: %v", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetOperatorPasswordHash(string(hash)); err != nil {
|
||||
s.logger.Printf("[ERROR] Change-password: persist failed: %v", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] Operator login password changed via Configuration UI from %s", r.RemoteAddr)
|
||||
http.Redirect(w, r, "/configuration?flash=pw_changed", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,24 @@
|
||||
{{if eq .Flash "artifact_sha_invalid"}}
|
||||
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "pw_changed"}}
|
||||
<div class="flash flash-success">Login password changed. It is already in effect — use it next time you sign in. Existing sessions stay logged in.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "pw_current_wrong"}}
|
||||
<div class="flash flash-error">Current password is incorrect — password unchanged.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "pw_too_short"}}
|
||||
<div class="flash flash-error">New password is too short (minimum 8 characters) — password unchanged.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "pw_too_long"}}
|
||||
<div class="flash flash-error">New password is too long (maximum 72 characters) — password unchanged.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "pw_mismatch"}}
|
||||
<div class="flash flash-error">New password and confirmation don't match — password unchanged.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "pw_unchanged"}}
|
||||
<div class="flash flash-error">New password is the same as the current one — nothing changed.</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Phase 2 managed updates: global controller-version floor. ITS OWN card, separate from the
|
||||
Day-0 artifact manifest below (a manifest save must NEVER touch the live floor — the
|
||||
@@ -178,6 +196,47 @@
|
||||
</script>
|
||||
</section>
|
||||
|
||||
<!-- Operator login password (v0.54.0). Changing it here writes a hub_settings DB override that
|
||||
WINS over the hub.yaml ConfigMap seed (auth.password_hash); the ConfigMap stays the
|
||||
break-glass fallback (blank the DB row / edit the manifest to reset a lost password).
|
||||
Requires the current password. Existing sessions are intentionally kept valid. -->
|
||||
<section class="card">
|
||||
<h3 style="margin-top: 0;">Login password</h3>
|
||||
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
||||
The password for signing in to this hub UI. <strong>Changing it takes effect immediately</strong>
|
||||
for the next sign-in — your current session stays logged in. Enter your current password to confirm.
|
||||
If you ever lose it, the deployment ConfigMap (<code>auth.password_hash</code>) remains the reset path.
|
||||
</p>
|
||||
<form method="POST" action="/configuration/password" style="display: grid; grid-template-columns: auto 20em; gap: 0.5rem; align-items: center; max-width: 40em;"
|
||||
onsubmit="return felhomCheckNewPw(this);">
|
||||
{{.CSRFField}}
|
||||
<label style="font-size: 0.9em; color: #cbd5e1;">Current password</label>
|
||||
<input type="password" name="current_password" autocomplete="current-password" required style="padding: 0.3em 0.5em;">
|
||||
<label style="font-size: 0.9em; color: #cbd5e1;">New password</label>
|
||||
<input type="password" id="new_password" name="new_password" autocomplete="new-password" minlength="8" maxlength="72" required style="padding: 0.3em 0.5em;">
|
||||
<label style="font-size: 0.9em; color: #cbd5e1;">Confirm new password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" minlength="8" maxlength="72" required style="padding: 0.3em 0.5em;">
|
||||
<span></span>
|
||||
<span>
|
||||
<button class="btn btn-sm" type="submit">Change password</button>
|
||||
<span id="pw-client-err" style="margin-left: 0.6em; font-size: 0.8em; color: #f87171;"></span>
|
||||
</span>
|
||||
</form>
|
||||
<script>
|
||||
// Client-side pre-check only (the server re-validates authoritatively): catch the
|
||||
// mismatch before a round-trip so the operator sees it inline.
|
||||
function felhomCheckNewPw(form) {
|
||||
var a = form.new_password.value;
|
||||
var b = form.confirm_password.value;
|
||||
var err = document.getElementById('pw-client-err');
|
||||
err.textContent = '';
|
||||
if (a.length < 8) { err.textContent = 'New password must be at least 8 characters.'; return false; }
|
||||
if (a !== b) { err.textContent = 'New password and confirmation do not match.'; return false; }
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
</section>
|
||||
|
||||
<!-- Assets section -->
|
||||
<section class="card">
|
||||
<h3 style="margin-top: 0;">Assets</h3>
|
||||
|
||||
Reference in New Issue
Block a user