diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index c5daa44..7598f1a 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -129,6 +129,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { for _, rt := range app { ui.Handle(rt.pattern, gate(rt.handler)) } + ui.Handle("GET /ui/profile", gate(http.HandlerFunc(s.handleUIProfile))) // Admin panel: session + admin role. ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin)) ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin)) diff --git a/coordinator/internal/transport/http/templates/admin.html b/coordinator/internal/transport/http/templates/admin.html index 8bff008..37f704a 100644 --- a/coordinator/internal/transport/http/templates/admin.html +++ b/coordinator/internal/transport/http/templates/admin.html @@ -12,7 +12,7 @@

Admin panel

User & run control

-
← Dashboard
+
← DashboardProfile

Signed in as {{.Role}}. Promote or verify a user by their id, and control every job from the dashboard.

diff --git a/coordinator/internal/transport/http/templates/dashboard.html b/coordinator/internal/transport/http/templates/dashboard.html index 2381cf8..f4ee300 100644 --- a/coordinator/internal/transport/http/templates/dashboard.html +++ b/coordinator/internal/transport/http/templates/dashboard.html @@ -13,7 +13,7 @@

Local scientific compute

SciMesh control room

Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.

Live overview · refreshes every 2 seconds
-
{{if .Session}}Signed in · {{.Session.Role}}{{end}}{{if and .Session (eq .Session.Role "admin")}}Admin{{end}}+ New similarity search{{if .Session}}
{{end}}
+
{{if .Session}}Signed in · {{.Session.Role}}{{end}}{{if .Session}}Profile{{end}}{{if and .Session (eq .Session.Role "admin")}}Admin{{end}}+ New similarity search{{if .Session}}
{{end}}
How a search becomes a result
01Upload TSVThe coordinator validates and slices the dataset.
02Run shardsWorkers fingerprint molecules and return shard top-k CSVs.
03Merge exactlyThe coordinator ranks retained candidates deterministically.
04Download CSVA checksum-protected global result is ready.
diff --git a/coordinator/internal/transport/http/templates/job.html b/coordinator/internal/transport/http/templates/job.html index 49f8289..c9c77b2 100644 --- a/coordinator/internal/transport/http/templates/job.html +++ b/coordinator/internal/transport/http/templates/job.html @@ -12,7 +12,7 @@
-
← Back to control room{{if .Session}}
{{end}}
+
← Back to control room{{if .Session}}
Profile
{{end}}

{{workloadLabel .Workload}}

Live pipeline

One job, shown from accepted input through its final coordinator-owned scientific result.

Live · refreshes every 2 seconds
{{statusLabel .Status}}

{{statusHint .Status}}

Completed shards are preserved.

{{.Completed}} of {{.Total}} shards complete

{{.Total}}total shards
{{.Completed}}completed
{{.Pending}}waiting
{{add .Leased .Running}}with workers
{{.Failed}}failed
{{.Cancelled}}stopped
diff --git a/coordinator/internal/transport/http/templates/profile.html b/coordinator/internal/transport/http/templates/profile.html new file mode 100644 index 0000000..c84ed30 --- /dev/null +++ b/coordinator/internal/transport/http/templates/profile.html @@ -0,0 +1,32 @@ +{{define "profile.html"}} + + + + + + Profile · SciMesh + + + +
+
+

Account

Your profile

+ +
+ + {{if .Error}}
{{.Error}}
{{end}} + {{with .Profile}} +
+
User id{{.ID}}
+
Email{{.Email}}
+
Role{{.Role}}
+
Verified contributor{{if .Verified}}yes{{else}}no{{end}}
+
Member since{{.CreatedAt}}
+
+

Your user id is what the coordinator stores as the owner of every job you submit. Give it to an admin to be promoted or verified.

+ {{end}} +
+ + +{{end}} diff --git a/coordinator/internal/transport/http/ui_admin.go b/coordinator/internal/transport/http/ui_admin.go index 5d4e05f..6287b91 100644 --- a/coordinator/internal/transport/http/ui_admin.go +++ b/coordinator/internal/transport/http/ui_admin.go @@ -2,6 +2,7 @@ package http import ( "context" + "io" "net/http" "net/url" "strings" @@ -68,7 +69,7 @@ func (s *Server) handleUIAdminUserAction(w http.ResponseWriter, r *http.Request) return } - status, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+userID+"/"+action, c.Value) + status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+userID+"/"+action, c.Value) if err != nil { s.log.Error("admin action proxy", "err", err, "action", action) http.Redirect(w, r, "/ui/admin?error=service+unavailable", http.StatusSeeOther) @@ -89,21 +90,25 @@ func (s *Server) handleUIAdminUserAction(w http.ResponseWriter, r *http.Request) // callUserserviceAuthed makes an authenticated call to the userservice, passing // the caller's JWT through as a bearer token. Used for admin actions; login and // registration use the unauthenticated callUserservice. -func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer string) (int, error) { +func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer string) (int, []byte, error) { // path is not attacker-controlled: the caller composes it only from a // uuid-validated id and an action from a fixed whitelist, and the host is // the operator-configured userservice — so the SSRF taint gosec sees here // cannot reach an arbitrary destination. req, err := http.NewRequestWithContext(ctx, method, s.userserviceURL+path, nil) //nolint:gosec // G704: path is validated, host is config if err != nil { - return 0, err + return 0, nil, err } req.Header.Set("Authorization", "Bearer "+bearer) resp, err := s.httpClient.Do(req) //nolint:gosec // G704: see above if err != nil { - return 0, err + return 0, nil, err } defer func() { _ = resp.Body.Close() }() - return resp.StatusCode, nil + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, body, nil } diff --git a/coordinator/internal/transport/http/ui_profile.go b/coordinator/internal/transport/http/ui_profile.go new file mode 100644 index 0000000..320ed5b --- /dev/null +++ b/coordinator/internal/transport/http/ui_profile.go @@ -0,0 +1,49 @@ +package http + +import ( + "encoding/json" + "net/http" +) + +// profileView is the account data shown on the profile page, mirroring the +// userservice /me response. +type profileView struct { + ID string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + Verified bool `json:"verified"` + CreatedAt string `json:"created_at"` +} + +// handleUIProfile shows the signed-in user's own account. It proxies the +// session token to the userservice /me endpoint, which is the authority on the +// account (email and created_at are not in the JWT). +func (s *Server) handleUIProfile(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err != nil { + redirectToLogin(w, r) + return + } + status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/me", c.Value) + if err != nil { + s.log.Error("profile /me proxy", "err", err) + s.renderUI(w, "profile.html", map[string]any{"Error": "userservice unavailable"}) + return + } + if status == http.StatusUnauthorized { + clearSessionCookie(w, r) + redirectToLogin(w, r) + return + } + if status != http.StatusOK { + s.renderUI(w, "profile.html", map[string]any{"Error": "could not load your account"}) + return + } + + var p profileView + if err := json.Unmarshal(body, &p); err != nil { + s.renderUI(w, "profile.html", map[string]any{"Error": "could not read your account"}) + return + } + s.renderUI(w, "profile.html", map[string]any{"Profile": p}) +} diff --git a/coordinator/internal/transport/http/ui_profile_internal_test.go b/coordinator/internal/transport/http/ui_profile_internal_test.go new file mode 100644 index 0000000..882ab6a --- /dev/null +++ b/coordinator/internal/transport/http/ui_profile_internal_test.go @@ -0,0 +1,43 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestProfileProxiesMe(t *testing.T) { + var gotAuth, gotPath string + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth, gotPath = r.Header.Get("Authorization"), r.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","email":"me@example.com","role":"user","verified":false,"created_at":"2026-07-26T00:00:00Z"}`)) + })) + defer stub.Close() + s := newLoginServer(stub) + + req := newReq(http.MethodGet, "/ui/profile", nil) + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"}) + rec := httptest.NewRecorder() + s.handleUIProfile(rec, req) + + if gotAuth != "Bearer my.jwt" || gotPath != "/me" { + t.Fatalf("proxy: auth=%q path=%q", gotAuth, gotPath) + } + body := rec.Body.String() + if !strings.Contains(body, "me@example.com") || !strings.Contains(body, "11111111-1111-1111-1111-111111111111") { + t.Error("profile page must show the email and id") + } +} + +func TestProfileRedirectsWithoutCookie(t *testing.T) { + s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("must not call userservice without a session") + }))) + rec := httptest.NewRecorder() + s.handleUIProfile(rec, newReq(http.MethodGet, "/ui/profile", nil)) + if rec.Code != http.StatusSeeOther { + t.Errorf("no cookie: got %d, want 303 redirect", rec.Code) + } +}