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 Admin
NEURAL HUNT · PRIVATE CONTROL PLANE

Customer Service Admin

+ + Neural Hunt · Customer Admin
NEURAL HUNT · PRIVATE CONTROL PLANE

Customer Service Admin

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
NEURAL HUNT

Customer Service

PrePaid Worker · Task-Zuordnung · Reward Management