mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
webui: anonymous login, user menu, theme toggle, in-memory sessions
Anonymise the login page: generic "Login" title, lock icon instead of product logo — makes the device harder to fingerprint. Add a floating theme toggle button to the login page so users can switch theme before logging in (cycles auto→light→dark). Replace the four cluttered sidebar footer buttons (theme, download config, reboot, logout) with an always-visible topbar user menu. The dropdown shows the username, three theme options with a checkmark on the active one, download config, reboot, and logout. Refactor all handler page-data structs to embed PageData instead of repeating the four base fields, and add Username (sourced from the request context) so the topbar can display the logged-in user. Polish pass on the login card: spacing, focus styles, button weights. Replace the AES-encrypted-cookie session model with an opaque random token backed by an in-memory map. The vendored design embedded the user's username, password, CSRF, and feature flags into the cookie and persisted the AES key under /var/lib/misc, making the keyfile a passive credential-recovery oracle — anyone who could read it could decrypt any captured cookie and recover the plaintext admin password. Now: cookie is 32 bytes of crypto/rand, base64-url-encoded; sessions live only in process memory; reboot/respawn/upgrade drops every active session and the UI's 401 handler bounces the user to /login. Sliding-window refresh on user activity, janitor goroutine sweeps expired entries. --session-key flag and /var/lib/misc/webui-session.key are gone. Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@ build:
|
||||
go build -ldflags="-s -w" -o $(BINARY) .
|
||||
|
||||
dev: build
|
||||
go run . --listen :10000 --session-key /tmp/webui-session.key --insecure-tls $(ARGS)
|
||||
go run . --listen :10000 --insecure-tls $(ARGS)
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
@@ -50,7 +50,6 @@ GOOS=linux GOARCH=arm64 make build
|
||||
|-------------------|-----------------------------------|-------------------------------------------|
|
||||
| `--listen` | `:10000` | Address to listen on |
|
||||
| `--restconf` | `http://localhost:8080/restconf` | RESTCONF base URL of the device |
|
||||
| `--session-key` | `/var/lib/misc/webui-session.key` | Path to persistent session encryption key |
|
||||
| `--insecure-tls` | `false` | Disable TLS certificate verification |
|
||||
|
||||
The RESTCONF URL can also be set via the `RESTCONF_URL` environment
|
||||
|
||||
@@ -3,154 +3,128 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kernelkit/webui/internal/security"
|
||||
)
|
||||
|
||||
const sessionTimeout = 1 * time.Hour
|
||||
|
||||
type tokenPayload struct {
|
||||
Username string `json:"u"`
|
||||
Password string `json:"p"`
|
||||
CsrfToken string `json:"c"`
|
||||
CreatedAt int64 `json:"t"`
|
||||
Features map[string]bool `json:"f,omitempty"`
|
||||
// sessionEntry is the in-memory state of a single authenticated
|
||||
// session. The user's password is kept alongside the token so the
|
||||
// webui can re-authenticate to RESTCONF on the user's behalf on each
|
||||
// request; it is never written to disk or surfaced over the wire.
|
||||
type sessionEntry struct {
|
||||
username string
|
||||
password string
|
||||
csrfToken string
|
||||
features map[string]bool
|
||||
lastSeenAt time.Time
|
||||
}
|
||||
|
||||
// SessionStore issues and validates stateless encrypted tokens.
|
||||
// The cookie value is a base64url-encoded AES-256-GCM sealed blob
|
||||
// containing the user's credentials and a creation timestamp.
|
||||
// No server-side session map is needed — only the AES key must
|
||||
// persist across restarts.
|
||||
// SessionStore issues opaque random session tokens backed by an
|
||||
// in-memory map. Nothing is persisted: a webui restart drops every
|
||||
// active session and the UI's 401 handler surfaces a fresh login
|
||||
// page.
|
||||
type SessionStore struct {
|
||||
aead cipher.AEAD
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*sessionEntry
|
||||
}
|
||||
|
||||
// NewSessionStore creates a store. If keyFile is non-empty, the AES
|
||||
// key is read from that path (or generated and written there on first
|
||||
// run). If keyFile is empty, a random ephemeral key is used.
|
||||
func NewSessionStore(keyFile string) (*SessionStore, error) {
|
||||
key, err := loadOrCreateKey(keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// NewSessionStore returns a fresh store and starts a janitor
|
||||
// goroutine that sweeps expired entries once a minute.
|
||||
func NewSessionStore() *SessionStore {
|
||||
s := &SessionStore{
|
||||
sessions: make(map[string]*sessionEntry),
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SessionStore{aead: aead}, nil
|
||||
go s.janitor()
|
||||
return s
|
||||
}
|
||||
|
||||
// Create returns an encrypted token carrying the user's credentials and capabilities.
|
||||
// Create issues a session for the given credentials, returning the
|
||||
// session token (cookie value) and a freshly minted CSRF token.
|
||||
func (s *SessionStore) Create(username, password string, features map[string]bool) (string, string, error) {
|
||||
csrf := randomToken()
|
||||
token, err := s.CreateWithCSRF(username, password, csrf, features)
|
||||
return token, csrf, err
|
||||
}
|
||||
|
||||
// CreateWithCSRF returns an encrypted token carrying the user's credentials,
|
||||
// capabilities, and a bound CSRF token.
|
||||
func (s *SessionStore) CreateWithCSRF(username, password, csrf string, features map[string]bool) (string, error) {
|
||||
payload, err := json.Marshal(tokenPayload{
|
||||
Username: username,
|
||||
Password: password,
|
||||
CsrfToken: csrf,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Features: features,
|
||||
})
|
||||
token, err := security.RandomToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", fmt.Errorf("session token: %w", err)
|
||||
}
|
||||
csrf, err := security.RandomToken()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("csrf token: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
s.mu.Lock()
|
||||
s.sessions[token] = &sessionEntry{
|
||||
username: username,
|
||||
password: password,
|
||||
csrfToken: csrf,
|
||||
features: features,
|
||||
lastSeenAt: time.Now(),
|
||||
}
|
||||
|
||||
sealed := s.aead.Seal(nonce, nonce, payload, nil)
|
||||
return base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||
s.mu.Unlock()
|
||||
return token, csrf, nil
|
||||
}
|
||||
|
||||
// Lookup decrypts a token and returns the credentials and capabilities if valid.
|
||||
// Lookup returns the credentials associated with a session token, or
|
||||
// ok=false if the token is unknown or expired. An expired entry is
|
||||
// reaped as a side effect.
|
||||
func (s *SessionStore) Lookup(token string) (username, password, csrf string, features map[string]bool, ok bool) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return "", "", "", nil, false
|
||||
s.mu.RLock()
|
||||
e, present := s.sessions[token]
|
||||
if present && time.Since(e.lastSeenAt) <= sessionTimeout {
|
||||
username, password, csrf, features = e.username, e.password, e.csrfToken, e.features
|
||||
s.mu.RUnlock()
|
||||
return username, password, csrf, features, true
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
ns := s.aead.NonceSize()
|
||||
if len(raw) < ns {
|
||||
return "", "", "", nil, false
|
||||
// Slow path: the entry was either missing or expired at read
|
||||
// time. Re-check under the write lock — Refresh may have
|
||||
// updated lastSeenAt in the gap — and only delete if still
|
||||
// expired.
|
||||
if present {
|
||||
s.mu.Lock()
|
||||
if e, ok := s.sessions[token]; ok && time.Since(e.lastSeenAt) > sessionTimeout {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
plaintext, err := s.aead.Open(nil, raw[:ns], raw[ns:], nil)
|
||||
if err != nil {
|
||||
return "", "", "", nil, false
|
||||
}
|
||||
|
||||
var p tokenPayload
|
||||
if err := json.Unmarshal(plaintext, &p); err != nil {
|
||||
return "", "", "", nil, false
|
||||
}
|
||||
|
||||
if time.Since(time.Unix(p.CreatedAt, 0)) > sessionTimeout {
|
||||
return "", "", "", nil, false
|
||||
}
|
||||
|
||||
return p.Username, p.Password, p.CsrfToken, p.Features, true
|
||||
return "", "", "", nil, false
|
||||
}
|
||||
|
||||
// Delete is a no-op for stateless tokens (the cookie is cleared by
|
||||
// the caller), but kept to satisfy the existing logout flow.
|
||||
func (s *SessionStore) Delete(token string) {}
|
||||
|
||||
// loadOrCreateKey returns a 32-byte AES key. When path is non-empty
|
||||
// the key is persisted so sessions survive restarts.
|
||||
func loadOrCreateKey(path string) ([32]byte, error) {
|
||||
var key [32]byte
|
||||
|
||||
if path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil && len(data) == 32 {
|
||||
copy(key[:], data)
|
||||
return key, nil
|
||||
}
|
||||
// Refresh extends a session's lifetime by resetting its last-seen
|
||||
// timestamp. Called by the auth middleware on each user-driven
|
||||
// request so an active session doesn't expire mid-use. No-op if the
|
||||
// token is unknown.
|
||||
func (s *SessionStore) Refresh(token string) {
|
||||
s.mu.Lock()
|
||||
if e, ok := s.sessions[token]; ok {
|
||||
e.lastSeenAt = time.Now()
|
||||
}
|
||||
|
||||
if _, err := io.ReadFull(rand.Reader, key[:]); err != nil {
|
||||
return key, fmt.Errorf("generate session key: %w", err)
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return key, fmt.Errorf("create key directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, key[:], 0600); err != nil {
|
||||
return key, fmt.Errorf("write session key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return key, nil
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func randomToken() string {
|
||||
var b [32]byte
|
||||
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
|
||||
return ""
|
||||
// Delete revokes a session. Called by logout and by Lookup on
|
||||
// expiration.
|
||||
func (s *SessionStore) Delete(token string) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, token)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *SessionStore) janitor() {
|
||||
t := time.NewTicker(1 * time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
cutoff := time.Now().Add(-sessionTimeout)
|
||||
s.mu.Lock()
|
||||
for token, e := range s.sessions {
|
||||
if e.lastSeenAt.Before(cutoff) {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/kernelkit/webui/internal/restconf"
|
||||
"github.com/kernelkit/webui/internal/security"
|
||||
)
|
||||
|
||||
// PageData is the base template data passed to every page.
|
||||
type PageData struct {
|
||||
Username string
|
||||
CsrfToken string
|
||||
PageTitle string
|
||||
ActivePage string
|
||||
@@ -19,3 +22,13 @@ type PageData struct {
|
||||
func csrfToken(ctx context.Context) string {
|
||||
return security.TokenFromContext(ctx)
|
||||
}
|
||||
|
||||
func newPageData(r *http.Request, page, title string) PageData {
|
||||
return PageData{
|
||||
Username: restconf.CredentialsFromContext(r.Context()).Username,
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
PageTitle: title,
|
||||
ActivePage: page,
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,12 +67,9 @@ type ContainerEntry struct {
|
||||
|
||||
// containersData is the template data for the containers page.
|
||||
type containersData struct {
|
||||
CsrfToken string
|
||||
PageTitle string
|
||||
ActivePage string
|
||||
Capabilities *Capabilities
|
||||
Containers []ContainerEntry
|
||||
Error string
|
||||
PageData
|
||||
Containers []ContainerEntry
|
||||
Error string
|
||||
}
|
||||
|
||||
// ContainersHandler serves the containers status page.
|
||||
@@ -84,10 +81,7 @@ type ContainersHandler struct {
|
||||
// Overview renders the containers list page.
|
||||
func (h *ContainersHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := containersData{
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
PageTitle: "Containers",
|
||||
ActivePage: "containers",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "containers", "Containers"),
|
||||
}
|
||||
|
||||
// Detach from the request context so that RESTCONF calls survive
|
||||
|
||||
@@ -177,12 +177,8 @@ type hwComponentJSON struct {
|
||||
// Template data structures.
|
||||
|
||||
type dashboardData struct {
|
||||
CsrfToken string
|
||||
Username string
|
||||
ActivePage string
|
||||
PageTitle string
|
||||
Capabilities *Capabilities
|
||||
Hostname string
|
||||
PageData
|
||||
Hostname string
|
||||
OSName string
|
||||
OSVersion string
|
||||
Machine string
|
||||
@@ -232,13 +228,8 @@ type DashboardHandler struct {
|
||||
|
||||
// Index renders the dashboard (GET /).
|
||||
func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
creds := restconf.CredentialsFromContext(r.Context())
|
||||
data := dashboardData{
|
||||
Username: creds.Username,
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
ActivePage: "dashboard",
|
||||
PageTitle: "Dashboard",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "dashboard", "Dashboard"),
|
||||
}
|
||||
|
||||
// Detach from the request context so that RESTCONF calls survive
|
||||
|
||||
@@ -44,12 +44,9 @@ type DHCPData struct {
|
||||
// ─── Page data ───────────────────────────────────────────────────────────────
|
||||
|
||||
type dhcpPageData struct {
|
||||
CsrfToken string
|
||||
PageTitle string
|
||||
ActivePage string
|
||||
Capabilities *Capabilities
|
||||
DHCP *DHCPData
|
||||
Error string
|
||||
PageData
|
||||
DHCP *DHCPData
|
||||
Error string
|
||||
}
|
||||
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
@@ -63,10 +60,7 @@ type DHCPHandler struct {
|
||||
// Overview renders the DHCP page (GET /dhcp).
|
||||
func (h *DHCPHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := dhcpPageData{
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
PageTitle: "DHCP Server",
|
||||
ActivePage: "dhcp",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "dhcp", "DHCP Server"),
|
||||
}
|
||||
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
@@ -58,12 +58,8 @@ type policyJSON struct {
|
||||
// Template data structures.
|
||||
|
||||
type firewallData struct {
|
||||
CsrfToken string
|
||||
Username string
|
||||
ActivePage string
|
||||
PageTitle string
|
||||
Capabilities *Capabilities
|
||||
Enabled bool
|
||||
PageData
|
||||
Enabled bool
|
||||
EnabledText string
|
||||
DefaultZone string
|
||||
Lockdown bool
|
||||
@@ -111,13 +107,8 @@ type FirewallHandler struct {
|
||||
|
||||
// Overview renders the firewall overview (GET /firewall).
|
||||
func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
creds := restconf.CredentialsFromContext(r.Context())
|
||||
data := firewallData{
|
||||
Username: creds.Username,
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
ActivePage: "firewall",
|
||||
PageTitle: "Firewall",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "firewall", "Firewall"),
|
||||
}
|
||||
|
||||
var fw firewallWrapper
|
||||
|
||||
@@ -184,13 +184,9 @@ type ethFrameStats struct {
|
||||
// Template data structures.
|
||||
|
||||
type interfacesData struct {
|
||||
CsrfToken string
|
||||
Username string
|
||||
ActivePage string
|
||||
Capabilities *Capabilities
|
||||
PageTitle string
|
||||
Interfaces []ifaceEntry
|
||||
Error string
|
||||
PageData
|
||||
Interfaces []ifaceEntry
|
||||
Error string
|
||||
}
|
||||
|
||||
type ifaceEntry struct {
|
||||
@@ -221,13 +217,8 @@ type InterfacesHandler struct {
|
||||
|
||||
// Overview renders the interfaces page (GET /interfaces).
|
||||
func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
creds := restconf.CredentialsFromContext(r.Context())
|
||||
data := interfacesData{
|
||||
Username: creds.Username,
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
ActivePage: "interfaces",
|
||||
PageTitle: "Interfaces",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "interfaces", "Interfaces"),
|
||||
}
|
||||
|
||||
var ifaces interfacesWrapper
|
||||
@@ -389,12 +380,8 @@ func makeIfaceEntry(iface ifaceJSON, indent string) ifaceEntry {
|
||||
|
||||
// Template data for the interface detail page.
|
||||
type ifaceDetailData struct {
|
||||
CsrfToken string
|
||||
Username string
|
||||
ActivePage string
|
||||
Capabilities *Capabilities
|
||||
PageTitle string
|
||||
Name string
|
||||
PageData
|
||||
Name string
|
||||
Type string
|
||||
Status string
|
||||
StatusUp bool
|
||||
@@ -486,11 +473,9 @@ func (h *InterfacesHandler) fetchInterface(r *http.Request, name string) (*iface
|
||||
}
|
||||
|
||||
// buildDetailData converts raw RESTCONF interface data to template data.
|
||||
func buildDetailData(username, csrf string, iface *ifaceJSON) ifaceDetailData {
|
||||
func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData {
|
||||
d := ifaceDetailData{
|
||||
Username: username,
|
||||
CsrfToken: csrf,
|
||||
Name: iface.Name,
|
||||
Name: iface.Name,
|
||||
Type: prettyIfType(iface.Type),
|
||||
Status: iface.OperStatus,
|
||||
StatusUp: iface.OperStatus == "up",
|
||||
@@ -727,7 +712,6 @@ func formatCount(n int64) string {
|
||||
// Detail renders the interface detail page (GET /interfaces/{name}).
|
||||
func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
creds := restconf.CredentialsFromContext(r.Context())
|
||||
|
||||
iface, err := h.fetchInterface(r, name)
|
||||
if err != nil {
|
||||
@@ -736,10 +720,8 @@ func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
data := buildDetailData(creds.Username, csrfToken(r.Context()), iface)
|
||||
data.ActivePage = "interfaces"
|
||||
data.PageTitle = "Interface " + name
|
||||
data.Capabilities = CapabilitiesFromContext(r.Context())
|
||||
data := buildDetailData(r, iface)
|
||||
data.PageData = newPageData(r, "interfaces", "Interface "+name)
|
||||
|
||||
tmplName := "iface-detail.html"
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
@@ -754,7 +736,6 @@ func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) {
|
||||
// Counters renders the counters fragment for htmx polling (GET /interfaces/{name}/counters).
|
||||
func (h *InterfacesHandler) Counters(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
creds := restconf.CredentialsFromContext(r.Context())
|
||||
|
||||
iface, err := h.fetchInterface(r, name)
|
||||
if err != nil {
|
||||
@@ -763,7 +744,7 @@ func (h *InterfacesHandler) Counters(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
data := buildDetailData(creds.Username, csrfToken(r.Context()), iface)
|
||||
data := buildDetailData(r, iface)
|
||||
|
||||
if err := h.CountersTemplate.ExecuteTemplate(w, "iface-counters", data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
|
||||
@@ -58,11 +58,7 @@ type certificateJSON struct {
|
||||
// Template data structures.
|
||||
|
||||
type keystoreData struct {
|
||||
CsrfToken string
|
||||
Username string
|
||||
ActivePage string
|
||||
PageTitle string
|
||||
Capabilities *Capabilities
|
||||
PageData
|
||||
SymmetricKeys []symKeyEntry
|
||||
AsymmetricKeys []asymKeyEntry
|
||||
Empty bool
|
||||
@@ -93,13 +89,8 @@ type KeystoreHandler struct {
|
||||
|
||||
// Overview renders the keystore overview (GET /keystore).
|
||||
func (h *KeystoreHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
creds := restconf.CredentialsFromContext(r.Context())
|
||||
data := keystoreData{
|
||||
Username: creds.Username,
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
ActivePage: "keystore",
|
||||
PageTitle: "Keystore",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "keystore", "Keystore"),
|
||||
}
|
||||
|
||||
var ks keystoreWrapper
|
||||
|
||||
@@ -30,12 +30,9 @@ type LLDPNeighbor struct {
|
||||
// ─── Page data ───────────────────────────────────────────────────────────────
|
||||
|
||||
type lldpPageData struct {
|
||||
CsrfToken string
|
||||
PageTitle string
|
||||
ActivePage string
|
||||
Capabilities *Capabilities
|
||||
Neighbors []LLDPNeighbor
|
||||
Error string
|
||||
PageData
|
||||
Neighbors []LLDPNeighbor
|
||||
Error string
|
||||
}
|
||||
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
@@ -49,10 +46,7 @@ type LLDPHandler struct {
|
||||
// Overview renders the LLDP page (GET /lldp).
|
||||
func (h *LLDPHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := lldpPageData{
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
PageTitle: "LLDP Neighbors",
|
||||
ActivePage: "lldp",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "lldp", "LLDP Neighbors"),
|
||||
}
|
||||
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
@@ -40,12 +40,9 @@ type NTPData struct {
|
||||
// ─── Page data ───────────────────────────────────────────────────────────────
|
||||
|
||||
type ntpPageData struct {
|
||||
CsrfToken string
|
||||
PageTitle string
|
||||
ActivePage string
|
||||
Capabilities *Capabilities
|
||||
NTP *NTPData
|
||||
Error string
|
||||
PageData
|
||||
NTP *NTPData
|
||||
Error string
|
||||
}
|
||||
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
@@ -59,10 +56,7 @@ type NTPHandler struct {
|
||||
// Overview renders the NTP page (GET /ntp).
|
||||
func (h *NTPHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := ntpPageData{
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
PageTitle: "NTP",
|
||||
ActivePage: "ntp",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "ntp", "NTP"),
|
||||
}
|
||||
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
@@ -41,10 +41,7 @@ type OSPFIface struct {
|
||||
}
|
||||
|
||||
type routingData struct {
|
||||
CsrfToken string
|
||||
PageTitle string
|
||||
ActivePage string
|
||||
Capabilities *Capabilities
|
||||
PageData
|
||||
Routes []RouteEntry
|
||||
OSPFNeighbors []OSPFNeighbor
|
||||
OSPFIfaces []OSPFIface
|
||||
@@ -180,10 +177,7 @@ type ospfNeighborJSON struct {
|
||||
|
||||
func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := routingData{
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
PageTitle: "Routing",
|
||||
ActivePage: "routing",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "routing", "Routing"),
|
||||
}
|
||||
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
@@ -106,12 +106,8 @@ type fwSlotBundle struct {
|
||||
// Template data for the firmware page.
|
||||
|
||||
type firmwareData struct {
|
||||
CsrfToken string
|
||||
Username string
|
||||
ActivePage string
|
||||
PageTitle string
|
||||
Capabilities *Capabilities
|
||||
Slots []slotEntry
|
||||
PageData
|
||||
Slots []slotEntry
|
||||
Installer *installerEntry
|
||||
Error string
|
||||
Message string
|
||||
@@ -135,14 +131,9 @@ type installerEntry struct {
|
||||
|
||||
// Firmware renders the firmware overview page (GET /firmware).
|
||||
func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) {
|
||||
creds := restconf.CredentialsFromContext(r.Context())
|
||||
data := firmwareData{
|
||||
Username: creds.Username,
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
ActivePage: "firmware",
|
||||
PageTitle: "Firmware",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
Message: r.URL.Query().Get("msg"),
|
||||
PageData: newPageData(r, "firmware", "Firmware"),
|
||||
Message: r.URL.Query().Get("msg"),
|
||||
}
|
||||
|
||||
var sw fwSoftwareWrapper
|
||||
|
||||
@@ -47,12 +47,9 @@ type WGTunnel struct {
|
||||
|
||||
// vpnData is the template data struct for the VPN page.
|
||||
type vpnData struct {
|
||||
CsrfToken string
|
||||
PageTitle string
|
||||
ActivePage string
|
||||
Capabilities *Capabilities
|
||||
Tunnels []WGTunnel
|
||||
Error string
|
||||
PageData
|
||||
Tunnels []WGTunnel
|
||||
Error string
|
||||
}
|
||||
|
||||
// VPNHandler serves the VPN/WireGuard status page.
|
||||
@@ -64,10 +61,7 @@ type VPNHandler struct {
|
||||
// Overview renders the VPN page (GET /vpn).
|
||||
func (h *VPNHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := vpnData{
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
PageTitle: "VPN",
|
||||
ActivePage: "vpn",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "vpn", "VPN"),
|
||||
}
|
||||
|
||||
// Detach from the request context so that RESTCONF calls survive
|
||||
|
||||
@@ -137,12 +137,9 @@ type WiFiScan struct {
|
||||
|
||||
// wifiData is the top-level template data for the WiFi page.
|
||||
type wifiData struct {
|
||||
CsrfToken string
|
||||
PageTitle string
|
||||
ActivePage string
|
||||
Capabilities *Capabilities
|
||||
Radios []WiFiRadio
|
||||
Error string
|
||||
PageData
|
||||
Radios []WiFiRadio
|
||||
Error string
|
||||
}
|
||||
|
||||
// WiFiHandler serves the WiFi status page.
|
||||
@@ -154,10 +151,7 @@ type WiFiHandler struct {
|
||||
// Overview renders the WiFi page (GET /wifi).
|
||||
func (h *WiFiHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := wifiData{
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
PageTitle: "WiFi",
|
||||
ActivePage: "wifi",
|
||||
Capabilities: CapabilitiesFromContext(r.Context()),
|
||||
PageData: newPageData(r, "wifi", "WiFi"),
|
||||
}
|
||||
|
||||
// Detach from the request context so that RESTCONF calls survive
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -35,7 +37,10 @@ func EnsureToken(w http.ResponseWriter, r *http.Request, preferred string) strin
|
||||
}
|
||||
}
|
||||
|
||||
token := randomToken()
|
||||
token, err := RandomToken()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: token,
|
||||
@@ -60,12 +65,14 @@ func TokenFromContext(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func randomToken() string {
|
||||
// RandomToken returns 32 bytes of crypto/rand, base64-url-encoded.
|
||||
// Used for both CSRF tokens and session-store entries.
|
||||
func RandomToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return ""
|
||||
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
|
||||
return "", fmt.Errorf("read random: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:])
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
func validToken(token string) bool {
|
||||
|
||||
@@ -15,10 +15,10 @@ import (
|
||||
|
||||
const cookieName = "session"
|
||||
|
||||
// authMiddleware checks the session cookie on every request, looks up
|
||||
// the session, and attaches decrypted credentials to the context.
|
||||
// Unauthenticated requests are redirected to /login (or get a 401 if
|
||||
// the request comes from HTMX).
|
||||
// authMiddleware checks the session cookie on every request, looks
|
||||
// the token up in the in-memory store, and attaches the session's
|
||||
// credentials to the context. Unauthenticated requests are
|
||||
// redirected to /login (or get a 401 if the request comes from HTMX).
|
||||
func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isPublicPath(r.URL.Path) {
|
||||
@@ -38,20 +38,12 @@ func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Sliding window: re-issue the cookie with a fresh timestamp.
|
||||
// Skip for background polling endpoints so they don't keep
|
||||
// the session alive indefinitely.
|
||||
// Sliding window: extend the session on user-driven requests.
|
||||
// Background polling endpoints are excluded so an idle user
|
||||
// (just leaving the dashboard open in a tab) still hits the
|
||||
// timeout.
|
||||
if !isPollingPath(r.URL.Path) {
|
||||
if fresh, err := store.CreateWithCSRF(username, password, csrf, features); err == nil {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: fresh,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: security.IsSecureRequest(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
store.Refresh(cookie.Value)
|
||||
}
|
||||
|
||||
ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{
|
||||
|
||||
+1
-5
@@ -31,14 +31,10 @@ func main() {
|
||||
|
||||
listen := flag.String("listen", ":10000", "address to listen on")
|
||||
restconfURL := flag.String("restconf", defaultRC, "RESTCONF base URL")
|
||||
sessionKey := flag.String("session-key", "/var/lib/misc/webui-session.key", "path to persistent session key file")
|
||||
insecureTLS := flag.Bool("insecure-tls", envBool("INSECURE_TLS"), "disable TLS certificate verification")
|
||||
flag.Parse()
|
||||
|
||||
store, err := auth.NewSessionStore(*sessionKey)
|
||||
if err != nil {
|
||||
log.Fatalf("session store: %v", err)
|
||||
}
|
||||
store := auth.NewSessionStore()
|
||||
|
||||
rc := restconf.NewClient(*restconfURL, *insecureTLS)
|
||||
|
||||
|
||||
+158
-45
@@ -146,7 +146,8 @@ a:hover {
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
@@ -157,13 +158,20 @@ a:hover {
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#content {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -1006,31 +1014,26 @@ details[open].nav-group > summary.nav-group-summary::after {
|
||||
.signal-poor { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* ==========================================================================
|
||||
Topbar & Theme Toggle
|
||||
Topbar & User Menu
|
||||
========================================================================== */
|
||||
|
||||
.topbar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--sidebar-fg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1rem;
|
||||
height: 48px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: #fff;
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.hamburger-btn {
|
||||
@@ -1040,7 +1043,7 @@ details[open].nav-group > summary.nav-group-summary::after {
|
||||
padding: 0.375rem;
|
||||
cursor: pointer;
|
||||
color: var(--fg-muted);
|
||||
display: none; /* Hidden by default; shown via media query */
|
||||
display: none;
|
||||
align-items: center;
|
||||
}
|
||||
.hamburger-btn:hover {
|
||||
@@ -1048,6 +1051,137 @@ details[open].nav-group > summary.nav-group-summary::after {
|
||||
border-color: var(--fg-muted);
|
||||
}
|
||||
|
||||
/* User menu button */
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.user-menu-btn:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.user-menu-btn .chevron {
|
||||
color: var(--fg-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.user-menu-btn[aria-expanded="true"] .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Dropdown panel — opens on hover */
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 190px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.375rem 0;
|
||||
z-index: 200;
|
||||
/* Hidden by default; shown on hover or when button is aria-expanded */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.user-menu:hover .user-dropdown,
|
||||
.user-menu-btn[aria-expanded="true"] + .user-dropdown {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dropdown-section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
padding: 0.25rem 0.875rem 0.125rem;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 0.375rem 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--fg-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item:focus {
|
||||
background: var(--border-subtle);
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
.dropdown-item svg { flex-shrink: 0; color: var(--fg-muted); transition: color 0.1s; }
|
||||
.dropdown-item:hover svg,
|
||||
.dropdown-item:focus svg { color: var(--fg); }
|
||||
.dropdown-item-danger:hover,
|
||||
.dropdown-item-danger:focus { color: var(--danger); }
|
||||
.dropdown-item-danger:hover svg,
|
||||
.dropdown-item-danger:focus svg { color: var(--danger); }
|
||||
|
||||
/* Theme checkmark — hidden by default, shown on active option */
|
||||
.theme-check { margin-left: auto; flex-shrink: 0; opacity: 0; transition: opacity 0.1s; }
|
||||
.theme-opt.is-active .theme-check { opacity: 1; }
|
||||
|
||||
/* Login page floating theme button */
|
||||
.login-theme-btn {
|
||||
position: fixed;
|
||||
bottom: 1.25rem;
|
||||
right: 1.25rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem;
|
||||
cursor: pointer;
|
||||
color: var(--fg-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.login-theme-btn:hover { color: var(--fg); border-color: var(--fg-muted); }
|
||||
|
||||
/* Login lock icon */
|
||||
.login-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive Layout
|
||||
========================================================================== */
|
||||
@@ -1059,7 +1193,7 @@ details[open].nav-group > summary.nav-group-summary::after {
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
z-index: 200;
|
||||
z-index: 300;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
width: var(--sidebar-width);
|
||||
@@ -1074,22 +1208,17 @@ details[open].nav-group > summary.nav-group-summary::after {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 100;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
#content {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet (769px–1024px): Icon-only sidebar */
|
||||
@@ -1115,22 +1244,6 @@ details[open].nav-group > summary.nav-group-summary::after {
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.sidebar-footer .btn-sidebar-action span:not(.nav-icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-footer .btn-logout span:not(.nav-icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-toggle span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+93
-28
@@ -135,35 +135,38 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// Dark mode toggle (auto / light / dark)
|
||||
// Theme (auto / light / dark) — shared by main app and login page
|
||||
(function() {
|
||||
function getTheme() {
|
||||
var m = document.cookie.split(';').map(function(c){return c.trim();}).find(function(c){return c.indexOf('theme=')===0;});
|
||||
return m ? m.split('=')[1] : null;
|
||||
}
|
||||
|
||||
function applyTheme(mode) {
|
||||
var el = document.documentElement;
|
||||
el.classList.remove('dark', 'light');
|
||||
if (mode === 'dark') {
|
||||
el.classList.add('dark');
|
||||
} else if (mode === 'light') {
|
||||
el.classList.add('light');
|
||||
}
|
||||
updateIcon(mode || 'auto');
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
if (mode === 'dark') document.documentElement.classList.add('dark');
|
||||
else if (mode === 'light') document.documentElement.classList.add('light');
|
||||
updateDropdownCheck(mode || 'auto');
|
||||
updateLoginToggleIcon(mode || 'auto');
|
||||
}
|
||||
function updateIcon(mode) {
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
|
||||
function updateDropdownCheck(mode) {
|
||||
document.querySelectorAll('.theme-opt').forEach(function(btn) {
|
||||
btn.classList.toggle('is-active', btn.getAttribute('data-theme') === mode);
|
||||
});
|
||||
}
|
||||
|
||||
function updateLoginToggleIcon(mode) {
|
||||
// Login page floating toggle — cycle auto→light→dark
|
||||
var btn = document.getElementById('login-theme-toggle');
|
||||
if (!btn) return;
|
||||
var icons = btn.querySelectorAll('svg');
|
||||
for (var i = 0; i < icons.length; i++) icons[i].style.display = 'none';
|
||||
var id = mode === 'dark' ? 'icon-dark' : mode === 'light' ? 'icon-light' : 'icon-auto';
|
||||
var active = btn.querySelector('#' + id);
|
||||
if (active) active.style.display = '';
|
||||
btn.setAttribute('aria-label',
|
||||
mode === 'dark' ? 'Theme: dark (click for auto)' :
|
||||
mode === 'light' ? 'Theme: light (click for dark)' :
|
||||
'Theme: auto (click for light)');
|
||||
var ids = {auto: 'lti-auto', light: 'lti-light', dark: 'lti-dark'};
|
||||
Object.keys(ids).forEach(function(k) {
|
||||
var el = btn.querySelector('#' + ids[k]);
|
||||
if (el) el.style.display = (k === mode) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function setTheme(mode) {
|
||||
if (mode) {
|
||||
document.cookie = 'theme=' + mode + '; path=/; max-age=31536000; samesite=lax';
|
||||
@@ -172,16 +175,32 @@
|
||||
}
|
||||
applyTheme(mode);
|
||||
}
|
||||
var saved = getTheme();
|
||||
applyTheme(saved);
|
||||
|
||||
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
||||
applyTheme(getTheme());
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.addEventListener('click', function() {
|
||||
var cur = getTheme();
|
||||
if (!cur) setTheme('light');
|
||||
else if (cur === 'light') setTheme('dark');
|
||||
else setTheme(null);
|
||||
// Apply checkmarks now that DOM is ready
|
||||
updateDropdownCheck(getTheme() || 'auto');
|
||||
|
||||
// Dropdown theme options (main app)
|
||||
document.querySelectorAll('.theme-opt').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var t = btn.getAttribute('data-theme');
|
||||
setTheme(t === 'auto' ? null : t);
|
||||
});
|
||||
});
|
||||
|
||||
// Login page floating toggle — cycles auto → light → dark → auto
|
||||
var loginBtn = document.getElementById('login-theme-toggle');
|
||||
if (loginBtn) {
|
||||
loginBtn.addEventListener('click', function() {
|
||||
var cur = getTheme();
|
||||
if (!cur || cur === 'auto') setTheme('light');
|
||||
else if (cur === 'light') setTheme('dark');
|
||||
else setTheme(null);
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -205,6 +224,25 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// User menu — hover handled by CSS; JS manages aria-expanded and keyboard
|
||||
(function() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var menu = document.getElementById('user-menu');
|
||||
var btn = document.getElementById('user-menu-btn');
|
||||
if (!menu || !btn) return;
|
||||
|
||||
menu.addEventListener('mouseenter', function() {
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
});
|
||||
menu.addEventListener('mouseleave', function() {
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') btn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Sidebar toggle (mobile)
|
||||
(function() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -230,3 +268,30 @@
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// ─── data-autofocus: WM-friendly focus on visible ──────────────────────────
|
||||
// Focus the [data-autofocus] element only when the page is actually
|
||||
// visible — calling .focus() on a background tab/desktop is treated
|
||||
// as an attention hint by Cinnamon/Muffin (and similar X11 WMs)
|
||||
// and pulls the whole browser window to the foreground.
|
||||
(function() {
|
||||
function focusAutofocusWhenVisible() {
|
||||
var el = document.querySelector('[data-autofocus]');
|
||||
if (!el) return;
|
||||
var apply = function () {
|
||||
if (document.visibilityState !== 'hidden') el.focus({ preventScroll: true });
|
||||
};
|
||||
if (document.visibilityState !== 'hidden') {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
var handler = function () {
|
||||
if (document.visibilityState !== 'hidden') {
|
||||
document.removeEventListener('visibilitychange', handler);
|
||||
apply();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handler);
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', focusAutofocusWhenVisible);
|
||||
})();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{.CsrfToken}}">
|
||||
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
|
||||
<title>{{if .PageTitle}}{{.PageTitle}} — {{end}}Infix</title>
|
||||
<title>{{if .PageTitle}}{{.PageTitle}} — {{end}}Management</title>
|
||||
<link rel="icon" type="image/png" href="/assets/img/jack.png">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
<script src="/assets/js/htmx.min.js"></script>
|
||||
@@ -15,21 +15,75 @@
|
||||
</head>
|
||||
<body hx-boost="true">
|
||||
<div id="conn-banner" class="conn-banner" hidden>Device unreachable</div>
|
||||
<div class="topbar">
|
||||
<div class="logo-area topbar-left"></div>
|
||||
<button id="hamburger-btn" class="hamburger-btn" aria-label="Open menu" aria-expanded="false">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layout">
|
||||
{{template "sidebar" .}}
|
||||
<main id="content">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
<div class="main-column">
|
||||
<div class="topbar">
|
||||
<button id="hamburger-btn" class="hamburger-btn" aria-label="Open menu" aria-expanded="false">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="topbar-right">
|
||||
<div class="user-menu" id="user-menu">
|
||||
<button class="user-menu-btn" id="user-menu-btn" aria-haspopup="true" aria-expanded="false">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
<span>{{.Username}}</span>
|
||||
<svg class="chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="user-dropdown" id="user-dropdown">
|
||||
<div class="dropdown-section-label">Theme</div>
|
||||
<button class="dropdown-item theme-opt" data-theme="auto">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor"></path></svg>
|
||||
Auto
|
||||
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
</button>
|
||||
<button class="dropdown-item theme-opt" data-theme="light">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
|
||||
Light
|
||||
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
</button>
|
||||
<button class="dropdown-item theme-opt" data-theme="dark">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
|
||||
Dark
|
||||
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a href="/config" class="dropdown-item" hx-boost="false" download>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||||
Download Config
|
||||
</a>
|
||||
<button class="dropdown-item dropdown-item-danger"
|
||||
hx-post="/reboot"
|
||||
hx-confirm="Reboot the device?"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
|
||||
Reboot
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<form method="POST" action="/logout">
|
||||
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
|
||||
<button type="submit" class="dropdown-item dropdown-item-danger">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<main id="content">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -160,38 +160,5 @@
|
||||
</details>
|
||||
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<button id="theme-toggle" class="btn btn-sidebar-action theme-toggle" aria-label="Toggle theme">
|
||||
<svg id="icon-auto" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor"></path>
|
||||
</svg>
|
||||
<svg id="icon-light" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg id="icon-dark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
<span>Theme</span>
|
||||
</button>
|
||||
<a href="/config" class="btn btn-sidebar-action" hx-boost="false" download>Download Config</a>
|
||||
<button class="btn btn-sidebar-action btn-danger-hover"
|
||||
hx-post="/reboot"
|
||||
hx-confirm="Reboot the device?"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML">Reboot</button>
|
||||
<form method="POST" action="/logout">
|
||||
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
|
||||
<button type="submit" class="btn btn-logout">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{.CsrfToken}}">
|
||||
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
|
||||
<title>Infix — Login</title>
|
||||
<title>Login</title>
|
||||
<link rel="icon" type="image/png" href="/assets/img/jack.png">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
<script src="/assets/js/htmx.min.js"></script>
|
||||
@@ -14,26 +14,52 @@
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<img src="/assets/img/logo.png" alt="Infix" class="login-logo">
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error" role="alert">{{.Error}}</div>
|
||||
{{end}}
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username"
|
||||
autocomplete="username" required autofocus>
|
||||
<div class="login-card">
|
||||
<div class="login-icon" aria-hidden="true">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Log in</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error" role="alert">{{.Error}}</div>
|
||||
{{end}}
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
{{/* data-autofocus instead of autofocus: the focus is applied
|
||||
from JS when the page becomes visible, so loading /login
|
||||
in a background tab / on another desktop doesn't pull
|
||||
the browser window forward in Linux WMs (Cinnamon /
|
||||
Muffin treat focus-on-load as an attention hint). */}}
|
||||
<input type="text" id="username" name="username"
|
||||
autocomplete="username" required data-autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Log in</button>
|
||||
</form>
|
||||
</div>
|
||||
<button id="login-theme-toggle" class="login-theme-btn" aria-label="Toggle theme">
|
||||
<svg id="lti-auto" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor"></path>
|
||||
</svg>
|
||||
<svg id="lti-light" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg id="lti-dark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user