v0.7.0: Phase 1 — Authentication, Persistence & Settings Page

- New settings.json persistence layer (internal/settings/settings.go)
  - Atomic write (tmp + rename), thread-safe with sync.RWMutex
  - Stores password hash overrides and DB validation cache
  - Auto-creates on first save, graceful handling if missing

- Auth improvements
  - Password resolution priority: settings.json > controller.yaml > none
  - Session duration extended to 7 days (was 24h)
  - ?next= redirect after session expiry (returns to original page)
  - Flash messages on login page (used after password change)
  - Conditional logout link (hidden when auth disabled)
  - Session invalidation on password change

- New Settings page (/settings)
  - Read-only system config display (customer, domain, git, backup, monitoring)
  - Password change form with validation (min 8 chars, match check)
  - Sidebar "Beállítások" item pinned to bottom above version

- DB validation persistence
  - Validation results saved to settings.json after each dump
  - Cached data survives container restarts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 17:26:59 +01:00
parent 0be1f2e547
commit 4053245be8
10 changed files with 514 additions and 25 deletions
+129
View File
@@ -0,0 +1,129 @@
package settings
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
)
// Settings holds customer-modifiable overrides and cached state.
// Persisted as a single JSON file (settings.json) in the data directory.
type Settings struct {
mu sync.RWMutex `json:"-"`
path string `json:"-"`
log *log.Logger `json:"-"`
// Auth
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
// Notification preferences (Phase 2 — define struct now, leave empty)
Notifications *NotificationPrefs `json:"notifications,omitempty"`
// Cached state
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
}
// NotificationPrefs is a placeholder for Phase 2 notification settings.
type NotificationPrefs struct{}
// DBValidationCache holds cached DB dump validation results.
type DBValidationCache struct {
ValidatedAt string `json:"validated_at"` // RFC3339
TableCount int `json:"table_count"`
HasHeader bool `json:"has_header"`
Error string `json:"error,omitempty"`
}
// Load reads settings from the given file path.
// Returns empty Settings if the file doesn't exist (not an error).
func Load(path string, logger *log.Logger) (*Settings, error) {
s := &Settings{
path: path,
log: logger,
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
logger.Printf("[INFO] No settings.json found, using defaults")
return s, nil
}
return nil, fmt.Errorf("reading settings file: %w", err)
}
if err := json.Unmarshal(data, s); err != nil {
return nil, fmt.Errorf("parsing settings file: %w", err)
}
logger.Printf("[DEBUG] Settings loaded from %s", path)
return s, nil
}
// Save writes settings to disk atomically (write to .tmp, rename).
// Caller must hold the write lock or call this from a method that does.
func (s *Settings) save() error {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("marshaling settings: %w", err)
}
tmpPath := s.path + ".tmp"
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return fmt.Errorf("creating settings dir: %w", err)
}
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return fmt.Errorf("writing tmp settings: %w", err)
}
if err := os.Rename(tmpPath, s.path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("renaming settings file: %w", err)
}
s.log.Printf("[DEBUG] Settings saved to %s", s.path)
return nil
}
// GetPasswordHash returns the stored password hash (thread-safe).
func (s *Settings) GetPasswordHash() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.PasswordHash
}
// SetPasswordHash updates the password hash and saves to disk.
func (s *Settings) SetPasswordHash(hash string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.PasswordHash = hash
return s.save()
}
// GetDBValidations returns a copy of the cached DB validations.
func (s *Settings) GetDBValidations() map[string]DBValidationCache {
s.mu.RLock()
defer s.mu.RUnlock()
if s.DBValidations == nil {
return nil
}
result := make(map[string]DBValidationCache, len(s.DBValidations))
for k, v := range s.DBValidations {
result[k] = v
}
return result
}
// SetDBValidation saves a validation result for a dump file and persists to disk.
func (s *Settings) SetDBValidation(filename string, cache DBValidationCache) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.DBValidations == nil {
s.DBValidations = make(map[string]DBValidationCache)
}
s.DBValidations[filename] = cache
return s.save()
}