This commit is contained in:
16
V4.2.3_SESSION_UI_FIX.md
Normal file
16
V4.2.3_SESSION_UI_FIX.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
3
internal/customerui/dist/admin/index.html
vendored
3
internal/customerui/dist/admin/index.html
vendored
@@ -1 +1,2 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Admin</title><link rel="stylesheet" href="/styles.css"></head><body><main><div class="eyebrow">NEURAL HUNT · PRIVATE CONTROL PLANE</div><h1>Customer Service Admin</h1><div id="msg" class="msg hidden"></div><section id="loginBox"><label>Admin<input id="user"></label><label>Passwort<input id="pass" type="password"></label><button id="login">ANMELDEN</button></section><section id="panel" class="hidden"><div class="head"><p>Dieser Listener gehört ausschließlich hinter VPN / privates Netz.</p><button id="logout">ABMELDEN</button></div><div id="manual" class="notice"></div><div id="customers"></div></section></main><script src="/app.js" defer></script></body></html>
|
||||
<!doctype html><html lang="de"><head>
|
||||
<link rel="icon" href="data:,"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Admin</title><link rel="stylesheet" href="/styles.css"></head><body><main><div class="eyebrow">NEURAL HUNT · PRIVATE CONTROL PLANE</div><h1>Customer Service Admin</h1><div id="msg" class="msg hidden"></div><section id="loginBox"><label>Admin<input id="user"></label><label>Passwort<input id="pass" type="password"></label><button id="login">ANMELDEN</button></section><section id="panel" class="hidden"><div class="head"><p>Dieser Listener gehört ausschließlich hinter VPN / privates Netz.</p><button id="logout">ABMELDEN</button></div><div id="manual" class="notice"></div><div id="customers"></div></section></main><script src="/app.js" defer></script></body></html>
|
||||
|
||||
8
internal/customerui/dist/public/app.js
vendored
8
internal/customerui/dist/public/app.js
vendored
@@ -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=>`<option value="${esc(t.id)}">${esc(taskName(t))} · ${t.range_bits} bit${t.paused?' · PAUSED':''}</option>`).join('');$('newTask').innerHTML=html}
|
||||
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
|
||||
3
internal/customerui/dist/public/index.html
vendored
3
internal/customerui/dist/public/index.html
vendored
@@ -1,5 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Service</title><link rel="stylesheet" href="/styles.css"></head>
|
||||
<html lang="de"><head>
|
||||
<link rel="icon" href="data:,"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Service</title><link rel="stylesheet" href="/styles.css"></head>
|
||||
<body><main>
|
||||
<header><div><div class="eyebrow">NEURAL HUNT</div><h1>Customer Service</h1><p class="muted">PrePaid Worker · Task-Zuordnung · Reward Management</p></div><button id="logout" class="ghost hidden">ABMELDEN</button></header>
|
||||
<div id="msg" class="msg hidden"></div>
|
||||
|
||||
Reference in New Issue
Block a user