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:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,14 +18,32 @@ type session struct {
|
||||
|
||||
const (
|
||||
sessionCookieName = "felhom_session"
|
||||
sessionMaxAge = 24 * time.Hour
|
||||
sessionMaxAge = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// effectivePasswordHash returns the active password hash using the priority:
|
||||
// 1. settings.json → password_hash (customer changed it)
|
||||
// 2. controller.yaml → web.password_hash (operator provisioned)
|
||||
// 3. Empty string → no auth required
|
||||
func (s *Server) effectivePasswordHash() string {
|
||||
if s.settings != nil {
|
||||
if h := s.settings.GetPasswordHash(); h != "" {
|
||||
return h
|
||||
}
|
||||
}
|
||||
return s.cfg.Web.PasswordHash
|
||||
}
|
||||
|
||||
// authEnabled returns true if a password is configured from any source.
|
||||
func (s *Server) authEnabled() bool {
|
||||
return s.effectivePasswordHash() != ""
|
||||
}
|
||||
|
||||
// RequireAuth returns middleware that checks for valid session or shows login.
|
||||
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth if no password is configured
|
||||
if s.cfg.Web.PasswordHash == "" {
|
||||
if !s.authEnabled() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -39,7 +58,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/login" {
|
||||
s.renderLogin(w, "")
|
||||
s.renderLogin(w, "", r.URL.Query().Get("flash"))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/logout" {
|
||||
@@ -55,7 +74,12 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
// Redirect to login with ?next= so we can return to the original page
|
||||
loginURL := "/login"
|
||||
if r.URL.Path != "/" && r.URL.Path != "/dashboard" {
|
||||
loginURL = "/login?next=" + url.QueryEscape(r.URL.RequestURI())
|
||||
}
|
||||
http.Redirect(w, r, loginURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,15 +90,17 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
password := r.FormValue("password")
|
||||
nextURL := r.FormValue("next")
|
||||
|
||||
if password == "" {
|
||||
s.renderLogin(w, "Kérjük adja meg a jelszót")
|
||||
s.renderLogin(w, "Kérjük adja meg a jelszót", "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(s.cfg.Web.PasswordHash), []byte(password)); err != nil {
|
||||
effectiveHash := s.effectivePasswordHash()
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
|
||||
s.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr)
|
||||
s.renderLogin(w, "Hibás jelszó")
|
||||
s.renderLogin(w, "Hibás jelszó", "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -91,7 +117,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
|
||||
s.logger.Printf("[INFO] Login from %s", r.RemoteAddr)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
|
||||
// Redirect to ?next= target if provided, otherwise to dashboard
|
||||
redirectTo := "/"
|
||||
if nextURL != "" && strings.HasPrefix(nextURL, "/") {
|
||||
redirectTo = nextURL
|
||||
}
|
||||
http.Redirect(w, r, redirectTo, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -123,6 +155,14 @@ func (s *Server) isValidSession(token string) bool {
|
||||
return ok && time.Now().Before(sess.expiresAt)
|
||||
}
|
||||
|
||||
// invalidateAllSessions clears all sessions, forcing re-login.
|
||||
// Used after password change.
|
||||
func (s *Server) invalidateAllSessions() {
|
||||
s.sessionsMu.Lock()
|
||||
s.sessions = make(map[string]*session)
|
||||
s.sessionsMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) cleanupSessions() {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
@@ -148,11 +188,12 @@ func (s *Server) Close() {
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) {
|
||||
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg, flashMsg string) {
|
||||
data := map[string]interface{}{
|
||||
"Title": "Bejelentkezés",
|
||||
"CustomerName": s.cfg.Customer.Name,
|
||||
"Error": errorMsg,
|
||||
"Flash": flashMsg,
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "login", data); err != nil {
|
||||
|
||||
@@ -3,10 +3,12 @@ package web
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) baseData(page, title string) map[string]interface{} {
|
||||
@@ -16,6 +18,7 @@ func (s *Server) baseData(page, title string) map[string]interface{} {
|
||||
"CustomerName": s.cfg.Customer.Name,
|
||||
"Domain": s.cfg.Customer.Domain,
|
||||
"Version": s.version,
|
||||
"AuthEnabled": s.authEnabled(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,3 +213,88 @@ func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
s.render(w, "backups", data)
|
||||
}
|
||||
|
||||
func (s *Server) settingsHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
data := s.baseData("settings", "Beállítások")
|
||||
|
||||
// System configuration (read-only display from controller.yaml)
|
||||
data["CustomerID"] = s.cfg.Customer.ID
|
||||
data["CustomerDomain"] = s.cfg.Customer.Domain
|
||||
data["GitRepoURL"] = s.cfg.Git.RepoURL
|
||||
data["GitSyncInterval"] = s.cfg.Git.SyncInterval
|
||||
data["BackupEnabled"] = s.cfg.Backup.Enabled
|
||||
data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule
|
||||
data["ResticSchedule"] = s.cfg.Backup.ResticSchedule
|
||||
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
|
||||
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
|
||||
data["HubEnabled"] = s.cfg.Hub.Enabled
|
||||
|
||||
s.render(w, "settings", data)
|
||||
}
|
||||
|
||||
func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
currentPassword := r.FormValue("current_password")
|
||||
newPassword := r.FormValue("new_password")
|
||||
confirmPassword := r.FormValue("confirm_password")
|
||||
|
||||
data := s.baseData("settings", "Beállítások")
|
||||
data["CustomerID"] = s.cfg.Customer.ID
|
||||
data["CustomerDomain"] = s.cfg.Customer.Domain
|
||||
data["GitRepoURL"] = s.cfg.Git.RepoURL
|
||||
data["GitSyncInterval"] = s.cfg.Git.SyncInterval
|
||||
data["BackupEnabled"] = s.cfg.Backup.Enabled
|
||||
data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule
|
||||
data["ResticSchedule"] = s.cfg.Backup.ResticSchedule
|
||||
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
|
||||
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
|
||||
data["HubEnabled"] = s.cfg.Hub.Enabled
|
||||
|
||||
// Validate current password
|
||||
effectiveHash := s.effectivePasswordHash()
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
|
||||
data["PasswordError"] = "Hibás jelenlegi jelszó"
|
||||
s.render(w, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate new password length
|
||||
if len(newPassword) < 8 {
|
||||
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
|
||||
s.render(w, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate passwords match
|
||||
if newPassword != confirmPassword {
|
||||
data["PasswordError"] = "A két jelszó nem egyezik"
|
||||
s.render(w, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate bcrypt hash
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to hash new password: %v", err)
|
||||
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
||||
s.render(w, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Save to settings.json
|
||||
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err)
|
||||
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
||||
s.render(w, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Password changed via settings page from %s", r.RemoteAddr)
|
||||
|
||||
// Invalidate all sessions (force re-login)
|
||||
s.invalidateAllSessions()
|
||||
|
||||
// Redirect to login with flash message
|
||||
flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.")
|
||||
http.Redirect(w, r, "/login?flash="+flash, http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
@@ -23,6 +24,7 @@ type Server struct {
|
||||
cpuCollector *system.CPUCollector
|
||||
backupMgr *backup.Manager
|
||||
scheduler *scheduler.Scheduler
|
||||
settings *settings.Settings
|
||||
logger *log.Logger
|
||||
version string
|
||||
tmpl *template.Template
|
||||
@@ -32,13 +34,14 @@ type Server struct {
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, logger *log.Logger, version string) *Server {
|
||||
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, logger *log.Logger, version string) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
stackMgr: stackMgr,
|
||||
cpuCollector: cpuCollector,
|
||||
backupMgr: backupMgr,
|
||||
scheduler: sched,
|
||||
settings: sett,
|
||||
logger: logger,
|
||||
version: version,
|
||||
sessions: make(map[string]*session),
|
||||
@@ -46,6 +49,16 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
|
||||
}
|
||||
s.loadTemplates()
|
||||
go s.cleanupSessions()
|
||||
|
||||
// Log auth source on startup
|
||||
if sett != nil && sett.GetPasswordHash() != "" {
|
||||
logger.Printf("[INFO] Auth: using password from settings.json")
|
||||
} else if cfg.Web.PasswordHash != "" {
|
||||
logger.Printf("[INFO] Auth: using password from controller.yaml")
|
||||
} else {
|
||||
logger.Printf("[INFO] Auth: no password configured — dashboard is open")
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -68,6 +81,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.backupsHandler(w, r)
|
||||
case path == "/monitoring":
|
||||
s.monitoringHandler(w, r)
|
||||
case path == "/settings":
|
||||
s.settingsHandler(w, r)
|
||||
case path == "/settings/password" && r.Method == http.MethodPost:
|
||||
s.settingsPasswordHandler(w, r)
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
|
||||
name := strings.TrimPrefix(path, "/stacks/")
|
||||
name = strings.TrimSuffix(name, "/logs")
|
||||
|
||||
@@ -19,9 +19,12 @@
|
||||
<li><a href="/backups" class="{{if eq .Page "backups"}}active{{end}}">Biztonsági mentés</a></li>
|
||||
<li><a href="/monitoring" class="{{if eq .Page "monitoring"}}active{{end}}">Rendszermonitor</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">v{{.Version}}</span>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
<div class="sidebar-bottom">
|
||||
<a href="/settings" class="sidebar-settings-link {{if eq .Page "settings"}}active{{end}}">⚙ Beállítások</a>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">v{{.Version}}</span>
|
||||
{{if .AuthEnabled}}<a href="/logout" class="logout-link">Kijelentkezés ↗</a>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
<div class="login-card">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
||||
<p class="login-subtitle">{{.CustomerName}}</p>
|
||||
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="next" id="next-field" value="">
|
||||
<div class="form-group">
|
||||
<label for="password">Jelszó</label>
|
||||
<input type="password" id="password" name="password" required autofocus
|
||||
@@ -23,6 +25,15 @@
|
||||
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
|
||||
<a href="https://felhom.eu" target="_blank">felhom.eu</a></p>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var next = params.get('next');
|
||||
if (next) {
|
||||
document.getElementById('next-field').value = next;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{{define "settings"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<h2>Beállítások</h2>
|
||||
</div>
|
||||
|
||||
<!-- Section A: System Configuration (read-only) -->
|
||||
<div class="settings-card">
|
||||
<h3>Rendszer konfiguráció</h3>
|
||||
<p class="settings-card-desc">Az üzemeltető által beállított értékek. Módosításhoz kérd az üzemeltetőt.</p>
|
||||
<div class="settings-grid">
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Ügyfél azonosító</span>
|
||||
<span class="settings-value mono">{{.CustomerID}}</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Ügyfél neve</span>
|
||||
<span class="settings-value">{{.CustomerName}}</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Domain</span>
|
||||
<span class="settings-value mono">{{.CustomerDomain}}</span>
|
||||
</div>
|
||||
{{if .GitRepoURL}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Alkalmazás sablon forrás</span>
|
||||
<span class="settings-value mono settings-value-truncate">{{.GitRepoURL}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Sablon szinkronizálás</span>
|
||||
<span class="settings-value mono">{{.GitSyncInterval}}</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Biztonsági mentés</span>
|
||||
<span class="settings-value">{{if .BackupEnabled}}<span class="state-text-green">✅ Aktív</span>{{else}}<span class="state-text-red">❌ Inaktív</span>{{end}}</span>
|
||||
</div>
|
||||
{{if .BackupEnabled}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Mentés ütemezés</span>
|
||||
<span class="settings-value mono">{{.DBDumpSchedule}} / {{.ResticSchedule}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Monitoring</span>
|
||||
<span class="settings-value">{{if .MonitoringEnabled}}<span class="state-text-green">✅ Aktív</span>{{else}}<span class="state-text-red">❌ Inaktív</span>{{end}}</span>
|
||||
</div>
|
||||
{{if .MonitoringEnabled}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Healthchecks URL</span>
|
||||
<span class="settings-value mono settings-value-truncate">{{if .HealthchecksBase}}{{.HealthchecksBase}}{{else}}–{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Hub jelentés</span>
|
||||
<span class="settings-value">{{if .HubEnabled}}<span class="state-text-green">✅ Aktív</span>{{else}}–{{end}}</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Controller verzió</span>
|
||||
<span class="settings-value mono">v{{.Version}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section B: Password Change -->
|
||||
<div class="settings-card">
|
||||
<h3>Jelszó módosítás</h3>
|
||||
{{if .AuthEnabled}}
|
||||
{{if .PasswordError}}<div class="alert alert-error">{{.PasswordError}}</div>{{end}}
|
||||
<form method="POST" action="/settings/password">
|
||||
<div class="form-group">
|
||||
<label for="current_password">Jelenlegi jelszó</label>
|
||||
<input type="password" id="current_password" name="current_password" required
|
||||
placeholder="Adja meg a jelenlegi jelszavát" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password">Új jelszó</label>
|
||||
<input type="password" id="new_password" name="new_password" required minlength="8"
|
||||
placeholder="Legalább 8 karakter" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Új jelszó megerősítése</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required minlength="8"
|
||||
placeholder="Jelszó mégegyszer" class="form-control">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Jelszó módosítása</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="alert alert-info">
|
||||
A jelszavas védelem nincs beállítva. Kérd az üzemeltetőt a beállításhoz.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
@@ -1611,6 +1611,82 @@ a.stat-card:hover {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* --- Settings page --- */
|
||||
.settings-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.settings-card h3 {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.settings-card-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: .85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.settings-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: .5rem .75rem;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.3);
|
||||
font-size: .9rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
.settings-row:last-child { border-bottom: none; }
|
||||
.settings-label {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.settings-value {
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
.settings-value.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.settings-value-truncate {
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Sidebar bottom section */
|
||||
.sidebar-bottom {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.sidebar-settings-link {
|
||||
display: block;
|
||||
padding: .75rem 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: .95rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.sidebar-settings-link:hover {
|
||||
color: var(--accent-light);
|
||||
background: rgba(0, 136, 204, 0.08);
|
||||
}
|
||||
.sidebar-settings-link.active {
|
||||
color: var(--accent-light);
|
||||
background: rgba(0, 136, 204, 0.12);
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media(max-width: 768px) {
|
||||
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
|
||||
|
||||
Reference in New Issue
Block a user