Latest Updates done, before integrating
This commit is contained in:
221
internal/auth/auth.go
Normal file
221
internal/auth/auth.go
Normal file
@@ -0,0 +1,221 @@
|
||||
// Package auth provides session-based authentication and CSRF protection
|
||||
// for the EMS web UI. Both the session token and CSRF token are persisted
|
||||
// to disk so they survive EMS restarts without logging out open browser tabs.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "ems_session"
|
||||
csrfField = "csrf_token"
|
||||
sessionFile = "session.token"
|
||||
csrfFile = "csrf.token"
|
||||
)
|
||||
|
||||
// Manager handles login, session cookies, and CSRF tokens.
|
||||
type Manager struct {
|
||||
username string
|
||||
password string
|
||||
tokenDir string
|
||||
mu sync.RWMutex
|
||||
session string
|
||||
csrf string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Manager and loads or generates persistent tokens from tokenDir.
|
||||
// If password is empty, authentication is disabled (open access).
|
||||
func New(username, password, tokenDir string, logger *slog.Logger) *Manager {
|
||||
m := &Manager{
|
||||
username: username,
|
||||
password: password,
|
||||
tokenDir: tokenDir,
|
||||
logger: logger,
|
||||
}
|
||||
m.session = m.loadOrGenerate(sessionFile)
|
||||
m.csrf = m.loadOrGenerate(csrfFile)
|
||||
return m
|
||||
}
|
||||
|
||||
// Enabled reports whether authentication is active.
|
||||
func (m *Manager) Enabled() bool { return m.password != "" }
|
||||
|
||||
// CSRFToken returns the persistent CSRF token to embed in HTML forms.
|
||||
func (m *Manager) CSRFToken() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.csrf
|
||||
}
|
||||
|
||||
// RequireAuth is middleware that redirects unauthenticated requests to /login.
|
||||
// Exempt paths: /login, /health (health checks don't need auth).
|
||||
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.Enabled() || r.URL.Path == "/login" || r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil || !m.validSession(cookie.Value) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyCSRF is middleware that checks the csrf_token field on POST requests.
|
||||
// On failure it redirects to / so the user gets a fresh form — avoids a
|
||||
// confusing error page after an EMS restart regenerates the token.
|
||||
func (m *Manager) VerifyCSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if m.Enabled() && r.Method == http.MethodPost && r.URL.Path != "/login" && r.URL.Path != "/health" {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
got := r.FormValue(csrfField)
|
||||
m.mu.RLock()
|
||||
ok := subtle.ConstantTimeCompare([]byte(got), []byte(m.csrf)) == 1
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
// Redirect to status page so the user gets a fresh form.
|
||||
m.logger.Warn("CSRF token mismatch — redirecting to status page", "path", r.URL.Path)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// LoginHandler serves GET /login (form) and POST /login (credential check).
|
||||
func (m *Manager) LoginHandler() http.HandlerFunc {
|
||||
tmpl := template.Must(template.New("login").Parse(loginTemplate))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
tmpl.Execute(w, nil) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
user := r.FormValue("username")
|
||||
pass := r.FormValue("password")
|
||||
|
||||
uOK := subtle.ConstantTimeCompare([]byte(user), []byte(m.username)) == 1
|
||||
pOK := subtle.ConstantTimeCompare([]byte(pass), []byte(m.password)) == 1
|
||||
if !uOK || !pOK {
|
||||
m.logger.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
|
||||
tmpl.Execute(w, "Ungültige Zugangsdaten") //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
session := m.session
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Set Secure flag when arriving via HTTPS reverse proxy.
|
||||
secure := r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: session,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
m.logger.Info("web UI login", "remote_addr", r.RemoteAddr)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// LogoutHandler clears the session cookie and redirects to /login.
|
||||
func (m *Manager) LogoutHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
m.logger.Info("web UI logout", "remote_addr", r.RemoteAddr)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) validSession(token string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return subtle.ConstantTimeCompare([]byte(token), []byte(m.session)) == 1
|
||||
}
|
||||
|
||||
func (m *Manager) loadOrGenerate(filename string) string {
|
||||
path := filepath.Join(m.tokenDir, filename)
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if t := strings.TrimSpace(string(data)); len(t) == 64 {
|
||||
return t
|
||||
}
|
||||
}
|
||||
token := newToken()
|
||||
if err := os.MkdirAll(m.tokenDir, 0o700); err == nil {
|
||||
_ = os.WriteFile(path, []byte(token), 0o600)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func newToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("auth: cannot generate random token: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
const loginTemplate = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>EMS · Anmelden</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui,sans-serif;background:#f1f5f9;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
||||
.card{background:#fff;border-radius:1rem;box-shadow:0 2px 12px rgba(0,0,0,.1);padding:2rem;width:100%;max-width:360px}
|
||||
h1{font-size:1.4rem;margin-bottom:1.5rem;color:#0f172a;text-align:center}
|
||||
label{display:block;font-size:.85rem;color:#64748b;margin-bottom:.3rem;margin-top:1rem}
|
||||
input{width:100%;padding:.65rem .8rem;border:1px solid #cbd5e1;border-radius:.5rem;font-size:1rem;outline:none}
|
||||
input:focus{border-color:#3b82f6;box-shadow:0 0 0 2px #bfdbfe}
|
||||
button{width:100%;margin-top:1.5rem;padding:.75rem;background:#2563eb;color:#fff;border:none;border-radius:.5rem;font-size:1rem;cursor:pointer}
|
||||
button:hover{background:#1d4ed8}
|
||||
.error{background:#fee2e2;color:#b91c1c;border-radius:.5rem;padding:.6rem .9rem;margin-top:1rem;font-size:.9rem;text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>☀️ Solar EMS</h1>
|
||||
<form method="post" action="/login">
|
||||
<label for="u">Benutzer</label>
|
||||
<input type="text" id="u" name="username" autocomplete="username" required autofocus>
|
||||
<label for="p">Passwort</label>
|
||||
<input type="password" id="p" name="password" autocomplete="current-password" required>
|
||||
{{if .}}<div class="error">{{.}}</div>{{end}}
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -191,7 +192,8 @@ func (f *ForecastConfig) FetchIntervalParsed() time.Duration {
|
||||
// EMSConfig holds operational settings for the EMS daemon.
|
||||
type EMSConfig struct {
|
||||
PollInterval string `yaml:"poll_interval"`
|
||||
ListenAddr string `yaml:"listen_addr"`
|
||||
ListenAddr string `yaml:"listen_addr"` // web UI address (authenticated)
|
||||
MetricsAddr string `yaml:"metrics_addr"` // Prometheus /metrics address (no auth, internal only)
|
||||
LogLevel string `yaml:"log_level"`
|
||||
StateFile string `yaml:"state_file"`
|
||||
RecoveryTimeout string `yaml:"recovery_timeout"`
|
||||
@@ -201,6 +203,8 @@ type EMSConfig struct {
|
||||
WWBoostDisableFile string `yaml:"ww_boost_disable_file"` // flag file path: presence = WW boost disabled
|
||||
TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal
|
||||
SessionLogFile string `yaml:"session_log_file"` // JSONL log of completed charge sessions
|
||||
HTTPUsername string `yaml:"http_username"` // login username (empty = no auth)
|
||||
HTTPPassword string `yaml:"http_password"` // login password
|
||||
}
|
||||
|
||||
func (e *EMSConfig) PollIntervalParsed() time.Duration {
|
||||
@@ -226,6 +230,13 @@ func (e *EMSConfig) OverrideTimeoutParsed() time.Duration {
|
||||
|
||||
// Load reads and parses the YAML config file at the given path.
|
||||
func Load(path string) (*Config, error) {
|
||||
// Warn if config file is readable by group or others (contains credentials).
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: config file %s has permissions %o — should be 0600 (contains credentials)\n", path, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config file: %w", err)
|
||||
@@ -256,5 +267,21 @@ func (c *Config) validate() error {
|
||||
if c.EMS.ListenAddr == "" {
|
||||
return fmt.Errorf("ems.listen_addr is required")
|
||||
}
|
||||
// Validate Shelly IPs to prevent SSRF via config manipulation.
|
||||
for name, dev := range map[string]ShellyDevice{
|
||||
"sg_ready": c.Shelly.SGReady,
|
||||
"wallbox_a": c.Shelly.WallboxA,
|
||||
"wallbox_b": c.Shelly.WallboxB,
|
||||
} {
|
||||
if dev.IP != "" && net.ParseIP(dev.IP) == nil {
|
||||
return fmt.Errorf("shelly.%s.ip %q is not a valid IP address", name, dev.IP)
|
||||
}
|
||||
}
|
||||
if c.EMS.HTTPUsername != "" && c.EMS.HTTPPassword == "" {
|
||||
return fmt.Errorf("ems.http_username is set but ems.http_password is empty")
|
||||
}
|
||||
if c.EMS.HTTPPassword != "" && c.EMS.HTTPUsername == "" {
|
||||
return fmt.Errorf("ems.http_password is set but ems.http_username is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -690,8 +690,12 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||
continue
|
||||
}
|
||||
|
||||
// Proactive wallboxes: only emergency-brake at SOCFloor
|
||||
if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) {
|
||||
// Intentional wallbox charging (proactive or trip mode): only emergency-brake at SOCFloor.
|
||||
// Trip mode uses ManualOverride=true, proactive charging uses ProactiveCharging=true —
|
||||
// both deserve soc_floor protection instead of the standard block_all gate.
|
||||
isIntentionalCharging := (cs.ProactiveCharging || cs.ManualOverride) &&
|
||||
(c == ConsumerWallboxA || c == ConsumerWallboxB)
|
||||
if isIntentionalCharging {
|
||||
floor := float64(e.cfg.CarCharging.SOCFloor)
|
||||
if floor > 0 && soc >= floor {
|
||||
continue // still above floor, keep charging
|
||||
@@ -967,7 +971,10 @@ func (e *Engine) RecoverState(states map[Consumer]DeviceStatus) {
|
||||
// On match: expired overrides are cleared, resuming normal EMS control.
|
||||
// Power readings (from PM-capable devices) update the low-power cycle counter for
|
||||
// car-not-charging detection.
|
||||
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration) {
|
||||
// When monitorOnly is true, hardware mismatches silently update engine state without
|
||||
// logging or applying override lockouts — the EMS cannot act anyway, so treating
|
||||
// every missed switch as a "manual override" would produce spurious log spam.
|
||||
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration, monitorOnly bool) {
|
||||
for c, status := range states {
|
||||
cs, ok := e.consumers[c]
|
||||
if !ok {
|
||||
@@ -975,21 +982,33 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
|
||||
}
|
||||
|
||||
if cs.Active != status.On {
|
||||
// External change detected
|
||||
e.logger.Info("manual override detected — external state change",
|
||||
"consumer", c.String(),
|
||||
"engine_state", cs.Active,
|
||||
"hardware_state", status.On,
|
||||
"override_until", now.Add(overrideTimeout).Format("15:04"),
|
||||
)
|
||||
cs.Active = status.On
|
||||
cs.ManualOverride = true
|
||||
cs.OverrideUntil = now.Add(overrideTimeout)
|
||||
cs.LowPowerCycles = 0
|
||||
if !status.On {
|
||||
cs.ActivatedAt = time.Time{}
|
||||
if monitorOnly {
|
||||
// In monitor-only mode the EMS cannot execute switch actions, so a
|
||||
// mismatch just means a pending action couldn't be carried out.
|
||||
// Silently realign engine state to hardware without locking.
|
||||
cs.Active = status.On
|
||||
if !status.On {
|
||||
cs.ActivatedAt = time.Time{}
|
||||
} else {
|
||||
cs.ActivatedAt = now
|
||||
}
|
||||
} else {
|
||||
cs.ActivatedAt = now
|
||||
// External change detected — log and apply override lockout
|
||||
e.logger.Info("manual override detected — external state change",
|
||||
"consumer", c.String(),
|
||||
"engine_state", cs.Active,
|
||||
"hardware_state", status.On,
|
||||
"override_until", now.Add(overrideTimeout).Format("15:04"),
|
||||
)
|
||||
cs.Active = status.On
|
||||
cs.ManualOverride = true
|
||||
cs.OverrideUntil = now.Add(overrideTimeout)
|
||||
cs.LowPowerCycles = 0
|
||||
if !status.On {
|
||||
cs.ActivatedAt = time.Time{}
|
||||
} else {
|
||||
cs.ActivatedAt = now
|
||||
}
|
||||
}
|
||||
} else if cs.ManualOverride && now.After(cs.OverrideUntil) {
|
||||
// Override expired and state matches — resume EMS control
|
||||
|
||||
@@ -344,6 +344,7 @@ func TestCarNotChargingReleasesWallbox(t *testing.T) {
|
||||
map[Consumer]DeviceStatus{ConsumerWallboxA: lowPower},
|
||||
base.Add(time.Duration(i+1)*2*time.Minute),
|
||||
time.Hour,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func (m *Mode) Set(active bool) error {
|
||||
}
|
||||
|
||||
if active {
|
||||
f, err := os.Create(m.filePath)
|
||||
f, err := os.OpenFile(m.filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -64,10 +64,12 @@ type Store struct {
|
||||
wwBoostDisabled *monitor.Mode
|
||||
tripMgr *trip.Manager
|
||||
carOptions []CarOption
|
||||
csrfToken string
|
||||
hasAuth bool
|
||||
}
|
||||
|
||||
// NewStore creates a new status store.
|
||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, carCharging config.CarChargingConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store {
|
||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, carCharging config.CarChargingConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption, csrfToken string, hasAuth bool) *Store {
|
||||
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||
for _, c := range consumerOrder {
|
||||
consumers[c] = &consumerRecord{}
|
||||
@@ -82,6 +84,8 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig,
|
||||
wwBoostDisabled: wwBoostDisabled,
|
||||
tripMgr: tm,
|
||||
carOptions: cars,
|
||||
csrfToken: csrfToken,
|
||||
hasAuth: hasAuth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +199,8 @@ type pageData struct {
|
||||
Consumers []consumerView
|
||||
Trip tripView
|
||||
CarOptions []CarOption
|
||||
CSRFToken string
|
||||
HasAuth bool // true when login is required — shows logout button
|
||||
}
|
||||
|
||||
// SyncConsumerStates updates active flags for all consumers directly from the
|
||||
@@ -289,6 +295,8 @@ func (s *Store) Handler() http.HandlerFunc {
|
||||
IsExporting: s.state.GridPowerW < 0,
|
||||
AbsGridW: abs(s.state.GridPowerW),
|
||||
HasPhaseData: hasPhase,
|
||||
CSRFToken: s.csrfToken,
|
||||
HasAuth: s.hasAuth,
|
||||
}
|
||||
if hasPhase {
|
||||
for _, ph := range []struct {
|
||||
@@ -782,9 +790,13 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
{{end}}
|
||||
{{if not .MonitorOnly}}
|
||||
<form method="post" action="/monitor">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="monitor-toggle-btn">⏸ Monitor</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .HasAuth}}
|
||||
<a href="/logout" style="font-size:.8rem;color:#94a3b8;text-decoration:none;padding:.3rem .6rem;border:1px solid #e2e8f0;border-radius:.4rem">Abmelden</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -792,6 +804,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
<div class="banner monitor">
|
||||
<span>⏸ Monitor-Only — Keine Schaltvorgänge</span>
|
||||
<form method="post" action="/monitor">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="resume-btn">▶ Automatik</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -907,6 +920,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
{{end}}
|
||||
{{if .CanOverride}}
|
||||
<form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
|
||||
{{if .Active}}
|
||||
<input type="hidden" name="state" value="off">
|
||||
@@ -928,10 +942,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
<div style="display:flex;flex-direction:column;gap:0.3rem;flex-shrink:0">
|
||||
{{if not .Unconfigured}}
|
||||
<form method="post" action="/ww/reset">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW-Solltemperatur auf Basiswert zurücksetzen, Boost für heute sperren">🌡️ Zurücksetzen</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/ww/boost">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
{{if .WWBoostDisabled}}
|
||||
<button type="submit" class="override-btn turn-on" style="width:100%" title="WW Boost durch PV-Überschuss wieder erlauben">✅ Boost ein</button>
|
||||
{{else}}
|
||||
@@ -948,6 +964,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
<div class="trip-header">
|
||||
<span class="trip-title">🚗 Fahrt geplant</span>
|
||||
<form method="post" action="/trip/cancel">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="trip-cancel">Abbrechen</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -966,6 +983,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
<div class="trip-form-card">
|
||||
<div class="section-title">🚗 Fahrtziel planen</div>
|
||||
<form method="post" action="/trip" class="trip-form">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="trip-row">
|
||||
<select name="wallbox" class="trip-select">
|
||||
<option value="wallbox_a">Wallbox A</option>
|
||||
|
||||
Reference in New Issue
Block a user