diff --git a/V4.2.3_SESSION_UI_FIX.md b/V4.2.3_SESSION_UI_FIX.md
new file mode 100644
index 0000000..ef041c9
--- /dev/null
+++ b/V4.2.3_SESSION_UI_FIX.md
@@ -0,0 +1,16 @@
+# V4.2.3 – Customer session/UI diagnostics
+
+This maintenance release fixes two confusing customer-portal behaviours:
+
+- A failure of a downstream API such as `/api/tasks` no longer makes the UI look logged out. `/api/me` is now the authority for the customer session; other portal sections load independently and display a concrete availability error.
+- Customer session cookies now carry both `Max-Age` and `Expires`. The public customer cookie uses `SameSite=Lax` so external payment-provider return navigations remain compatible while cross-site POST/fetch cookie use stays blocked. The private Customer Admin cookie remains `SameSite=Strict`.
+- Hosted-code pairing failures are written to the Customer Service log with the customer id and concrete internal error.
+- The bundled customer UI declares an empty favicon so `/favicon.ico` no longer produces distracting 404 console noise.
+
+For HTTPS production deployments set:
+
+```env
+CS_COOKIE_SECURE=true
+```
+
+If F5 still shows the login after this release, inspect `GET /api/me` in DevTools. A 401 there means the session cookie/database really is invalid; a 200 means the session is valid and any remaining portal problem is a downstream API error, which V4.2.3 now shows explicitly instead of replacing the portal with the login view.
diff --git a/internal/customer/server.go b/internal/customer/server.go
index 3d88569..d942957 100644
--- a/internal/customer/server.go
+++ b/internal/customer/server.go
@@ -112,11 +112,40 @@ func decode(r *http.Request, v any) error {
const customerCookie = "neuralhunt_customer_session"
const csAdminCookie = "neuralhunt_customer_admin"
+func (s *Service) cookieSameSite(name string) http.SameSite {
+ // The public customer portal may return from external payment providers. Lax
+ // still blocks cross-site fetch/POST cookie use while allowing normal top-level
+ // navigation back to the portal. Keep the private admin cookie Strict.
+ if name == customerCookie {
+ return http.SameSiteLaxMode
+ }
+ return http.SameSiteStrictMode
+}
+
func (s *Service) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
- http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: int(ttl.Seconds())})
+ now := time.Now().UTC()
+ http.SetCookie(w, &http.Cookie{
+ Name: name,
+ Value: value,
+ Path: "/",
+ HttpOnly: true,
+ Secure: s.cfg.CookieSecure,
+ SameSite: s.cookieSameSite(name),
+ MaxAge: int(ttl.Seconds()),
+ Expires: now.Add(ttl),
+ })
}
func (s *Service) clearCookie(w http.ResponseWriter, name string) {
- http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: -1})
+ http.SetCookie(w, &http.Cookie{
+ Name: name,
+ Value: "",
+ Path: "/",
+ HttpOnly: true,
+ Secure: s.cfg.CookieSecure,
+ SameSite: s.cookieSameSite(name),
+ MaxAge: -1,
+ Expires: time.Unix(1, 0).UTC(),
+ })
}
func (s *Service) customerID(r *http.Request) (string, error) {
c, err := r.Cookie(customerCookie)
@@ -312,6 +341,7 @@ func (s *Service) rewardIdentity(w http.ResponseWriter, r *http.Request) {
}
clientID, err := s.redeemRewardLink(r.Context(), in.LinkCode)
if err != nil {
+ log.Printf("customer reward identity %s pairing failed: %v", cid, err)
jsonOut(w, http.StatusBadGateway, map[string]string{"error": "Hosted-Code konnte nicht gekoppelt werden: " + err.Error()})
return
}
diff --git a/internal/customer/server_test.go b/internal/customer/server_test.go
index 94daa78..3b52da6 100644
--- a/internal/customer/server_test.go
+++ b/internal/customer/server_test.go
@@ -1,6 +1,9 @@
package customer
-import "testing"
+import (
+ "net/http"
+ "testing"
+)
func TestParseMoneyCentsExact(t *testing.T) {
cases := map[string]int64{
@@ -22,3 +25,13 @@ func TestParseMoneyCentsExact(t *testing.T) {
}
}
}
+
+func TestCustomerCookieUsesLaxAndAdminStrict(t *testing.T) {
+ s := &Service{}
+ if got := s.cookieSameSite(customerCookie); got != http.SameSiteLaxMode {
+ t.Fatalf("customer cookie SameSite = %v; want Lax", got)
+ }
+ if got := s.cookieSameSite(csAdminCookie); got != http.SameSiteStrictMode {
+ t.Fatalf("admin cookie SameSite = %v; want Strict", got)
+ }
+}
diff --git a/internal/customerui/dist/admin/index.html b/internal/customerui/dist/admin/index.html
index 744bd85..894900d 100644
--- a/internal/customerui/dist/admin/index.html
+++ b/internal/customerui/dist/admin/index.html
@@ -1 +1,2 @@
-
Neural Hunt · Customer AdminNEURAL HUNT · PRIVATE CONTROL PLANE
Customer Service Admin
Dieser Listener gehört ausschließlich hinter VPN / privates Netz.
+
+ Neural Hunt · Customer AdminNEURAL HUNT · PRIVATE CONTROL PLANE
Customer Service Admin
Dieser Listener gehört ausschließlich hinter VPN / privates Netz.
diff --git a/internal/customerui/dist/public/app.js b/internal/customerui/dist/public/app.js
index c60b3cb..9f091ab 100644
--- a/internal/customerui/dist/public/app.js
+++ b/internal/customerui/dist/public/app.js
@@ -1,9 +1,11 @@
const $=id=>document.getElementById(id);let state={me:null,tasks:[],workers:[]};
function msg(t,err=false){const e=$('msg');e.textContent=t;e.className='msg'+(err?' err':'');e.classList.remove('hidden');setTimeout(()=>e.classList.add('hidden'),5000)}
-async function api(path,opt={}){const o={credentials:'same-origin',...opt};if(o.body&&typeof o.body!=='string'){o.headers={...(o.headers||{}),'Content-Type':'application/json'};o.body=JSON.stringify(o.body)}const r=await fetch(path,o);const ct=r.headers.get('content-type')||'';const b=ct.includes('json')?await r.json():await r.text();if(!r.ok)throw new Error(b?.error||b||`HTTP ${r.status}`);return b}
+async function api(path,opt={}){const o={credentials:'same-origin',...opt};if(o.body&&typeof o.body!=='string'){o.headers={...(o.headers||{}),'Content-Type':'application/json'};o.body=JSON.stringify(o.body)}const r=await fetch(path,o);const ct=r.headers.get('content-type')||'';const b=ct.includes('json')?await r.json():await r.text();if(!r.ok){const e=new Error(b?.error||b||`HTTP ${r.status}`);e.status=r.status;e.path=path;throw e}return b}
const credits=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});
-async function boot(){try{await load()}catch(e){$('auth').classList.remove('hidden');$('portal').classList.add('hidden');$('logout').classList.add('hidden')}const q=new URLSearchParams(location.search);if(q.get('paypal')==='return'&&q.get('token')){try{await api('/api/billing/paypal/capture',{method:'POST',body:{order_id:q.get('token')}});history.replaceState({},'',location.pathname);msg('PayPal-Zahlung verbucht');await load()}catch(e){msg(e.message,true)}}}
-async function load(){const me=await api('/api/me');state.me=me;const [tasks,workers,ledger,packages]=await Promise.all([api('/api/tasks'),api('/api/workers'),api('/api/ledger'),api('/api/billing/packages')]);state.tasks=Array.isArray(tasks)?tasks:[];state.workers=workers||[];$('auth').classList.add('hidden');$('portal').classList.remove('hidden');$('logout').classList.remove('hidden');$('balance').textContent=credits(me.balance_micros);$('rate').textContent=credits(me.worker_rate_micros_per_minute);$('workerCount').textContent=workers.length;$('runningCount').textContent=`${workers.filter(x=>x.status==='running').length} aktiv`;$('rewardId').textContent=me.customer.reward_client_id||'noch nicht gekoppelt';renderTasks();renderWorkers();renderLedger(ledger||[]);renderPackages(packages)}
+function showAuth(){state.me=null;$('auth').classList.remove('hidden');$('portal').classList.add('hidden');$('logout').classList.add('hidden')}
+function showPortal(me){$('auth').classList.add('hidden');$('portal').classList.remove('hidden');$('logout').classList.remove('hidden');$('balance').textContent=credits(me.balance_micros);$('rate').textContent=credits(me.worker_rate_micros_per_minute);$('rewardId').textContent=me.customer.reward_client_id||'noch nicht gekoppelt'}
+async function boot(){try{await load()}catch(e){if(e.status===401)showAuth();else{showAuth();msg(`Portal konnte nicht geladen werden: ${e.message}`,true)}}const q=new URLSearchParams(location.search);if(q.get('paypal')==='return'&&q.get('token')){try{await api('/api/billing/paypal/capture',{method:'POST',body:{order_id:q.get('token')}});history.replaceState({},'',location.pathname);msg('PayPal-Zahlung verbucht');await load()}catch(e){msg(e.message,true)}}}
+async function load(){const me=await api('/api/me');state.me=me;showPortal(me);const reqs=[['tasks','/api/tasks'],['workers','/api/workers'],['ledger','/api/ledger'],['packages','/api/billing/packages']];const rs=await Promise.allSettled(reqs.map(([,path])=>api(path)));const errors=[];let tasks=[],workers=[],ledger=[],packages={paypal_enabled:false,packages:[]};rs.forEach((r,i)=>{const [name,path]=reqs[i];if(r.status==='fulfilled'){if(name==='tasks')tasks=r.value;if(name==='workers')workers=r.value;if(name==='ledger')ledger=r.value;if(name==='packages')packages=r.value}else{errors.push(`${path}: ${r.reason?.message||'Fehler'}`)}});state.tasks=Array.isArray(tasks)?tasks:[];state.workers=Array.isArray(workers)?workers:[];$('workerCount').textContent=state.workers.length;$('runningCount').textContent=`${state.workers.filter(x=>x.status==='running').length} aktiv`;renderTasks();renderWorkers();renderLedger(Array.isArray(ledger)?ledger:[]);renderPackages(packages||{paypal_enabled:false,packages:[]});if(errors.length)msg(`Session ist aktiv, aber Teile des Portals sind nicht erreichbar: ${errors.join(' · ')}`,true)}
function taskName(t){return t.display_name||`Task ${String(t.id).slice(-8)}`}
function renderTasks(){const html=state.tasks.map(t=>``).join('');$('newTask').innerHTML=html}
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
diff --git a/internal/customerui/dist/public/index.html b/internal/customerui/dist/public/index.html
index 648e5c0..0baea7d 100644
--- a/internal/customerui/dist/public/index.html
+++ b/internal/customerui/dist/public/index.html
@@ -1,5 +1,6 @@
-Neural Hunt · Customer Service
+
+ Neural Hunt · Customer Service