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:
Joachim Wiberg
2026-06-14 22:07:24 +02:00
parent 6271e40ce0
commit 917eec3f25
24 changed files with 543 additions and 434 deletions
+1 -1
View File
@@ -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)
-1
View File
@@ -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
+93 -119
View File
@@ -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[:])
}
+13
View File
@@ -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()),
}
}
+4 -10
View File
@@ -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
+3 -12
View File
@@ -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
+4 -10
View File
@@ -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())
+3 -12
View File
@@ -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
+11 -30
View File
@@ -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)
+2 -11
View File
@@ -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
+4 -10
View File
@@ -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())
+4 -10
View File
@@ -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())
+2 -8
View File
@@ -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())
+4 -13
View File
@@ -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
+4 -10
View File
@@ -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
+4 -10
View File
@@ -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
+12 -5
View File
@@ -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 {
+9 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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 (769px1024px): 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
View File
@@ -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);
})();
+68 -14
View File
@@ -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>
-33
View File
@@ -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}}
+46 -20
View File
@@ -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>