feat(coordinator): profile page at /ui/profile
Shows the signed-in user's id, email, role, verified status, and created-at by proxying the session token to the userservice /me endpoint (email/created_at are not in the JWT). Profile link added to the dashboard, job, and admin headers. Tests: /me proxy forwards the bearer and renders the account; redirect without a session.
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<main class="page">
|
||||
<header class="top">
|
||||
<div><p class="eyebrow">Admin panel</p><h1>User & run control</h1></div>
|
||||
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
|
||||
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><a href="/ui/profile">Profile</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
|
||||
</header>
|
||||
<p class="lead">Signed in as <strong>{{.Role}}</strong>. Promote or verify a user by their id, and control every job from the dashboard.</p>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<main class="page">
|
||||
<header class="top">
|
||||
<div><p class="eyebrow">Local scientific compute</p><h1 class="title">SciMesh control room</h1><p class="lead">Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.</p><div class="live"><i class="pulse"></i><span id="refresh-state">Live overview · refreshes every 2 seconds</span></div></div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}<a class="button" href="/ui/jobs/new">+ New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}<a class="button" href="/ui/jobs/new">+ New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
|
||||
</header>
|
||||
<section class="summary" aria-label="Pipeline summary">
|
||||
<div class="panel"><strong>How a search becomes a result</strong><div class="pipeline"><div class="flow-step"><span><i class="dot"></i>01</span><b>Upload TSV</b><small>The coordinator validates and slices the dataset.</small></div><div class="flow-step"><span><i class="dot"></i>02</span><b>Run shards</b><small>Workers fingerprint molecules and return shard top-k CSVs.</small></div><div class="flow-step"><span><i class="dot"></i>03</span><b>Merge exactly</b><small>The coordinator ranks retained candidates deterministically.</small></div><div class="flow-step"><span><i class="dot"></i>04</span><b>Download CSV</b><small>A checksum-protected global result is ready.</small></div></div></div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px"><a class="back" href="/ui">← Back to control room</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button type="submit" style="border:0;border-radius:10px;padding:9px 14px;background:#23344d;color:#dce8ff;font:inherit;font-weight:800;cursor:pointer">Log out</button></form>{{end}}</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px"><a class="back" href="/ui">← Back to control room</a>{{if .Session}}<div style="display:flex;gap:10px;align-items:center"><a class="back" href="/ui/profile">Profile</a><form method="post" action="/ui/logout" style="margin:0"><button type="submit" style="border:0;border-radius:10px;padding:9px 14px;background:#23344d;color:#dce8ff;font:inherit;font-weight:800;cursor:pointer">Log out</button></form></div>{{end}}</div>
|
||||
<div class="top"><div><p class="eyebrow">{{workloadLabel .Workload}}</p><h1 class="title">Live pipeline</h1><p class="subtitle">One job, shown from accepted input through its final coordinator-owned scientific result.</p></div><div class="live" id="refresh-state">Live · refreshes every 2 seconds</div></div>
|
||||
<section class="panel summary"><div class="summary-top"><div><span id="status" class="badge badge-{{statusClass .Status}}">{{statusLabel .Status}}</span><p id="hint" class="hint">{{statusHint .Status}}</p></div><div id="stop-wrap" {{if not (cancellable .Status)}}class="hidden"{{end}}><button id="stop-job" class="stop" type="button">Stop unfinished shards</button><div class="live">Completed shards are preserved.</div></div></div><div class="bar"><span id="progress-bar" style="width:{{progressPercent .Completed .Failed .Cancelled .Total}}%"></span></div><p class="progress-line" id="progress">{{.Completed}} of {{.Total}} shards complete</p><div class="metrics"><div class="metric"><b id="total">{{.Total}}</b><small>total shards</small></div><div class="metric"><b id="completed">{{.Completed}}</b><small>completed</small></div><div class="metric"><b id="pending">{{.Pending}}</b><small>waiting</small></div><div class="metric"><b id="active">{{add .Leased .Running}}</b><small>with workers</small></div><div class="metric"><b id="failed">{{.Failed}}</b><small>failed</small></div><div class="metric"><b id="cancelled">{{.Cancelled}}</b><small>stopped</small></div></div></section>
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "profile.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Profile · SciMesh</title>
|
||||
<style>
|
||||
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:720px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.top{display:flex;justify-content:space-between;align-items:center;gap:12px}.eyebrow{margin:0;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:6px 0 0;color:#f4f8ff;font-size:clamp(1.8rem,4vw,2.6rem);letter-spacing:-.04em}.btn{border:0;border-radius:9px;padding:10px 14px;font:inherit;font-weight:800;cursor:pointer;text-decoration:none}.btn-muted{background:#23344d;color:#dce8ff}.card{margin-top:24px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:6px 22px}.err{margin-top:24px;border-radius:10px;padding:12px 14px;background:#552334;color:#ff9bad;font-weight:700}.row{display:flex;justify-content:space-between;gap:16px;padding:15px 0;border-bottom:1px solid #1d3350}.row:last-child{border-bottom:0}.k{color:#9fb3cf}.v{color:#f2f7ff;font-weight:700;text-align:right;word-break:break-all}.mono{font-family:ui-monospace,SFMono-Regular,monospace;font-size:.9rem}.pill{display:inline-block;border-radius:999px;padding:3px 10px;font-size:.82rem;font-weight:800}.pill-yes{background:#123f34;color:#76efb5}.pill-no{background:#23344d;color:#b9cce9}.hint{margin-top:14px;color:#8ba2c2;font-size:.86rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="top">
|
||||
<div><p class="eyebrow">Account</p><h1>Your profile</h1></div>
|
||||
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
|
||||
</header>
|
||||
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
{{with .Profile}}
|
||||
<section class="card">
|
||||
<div class="row"><span class="k">User id</span><span class="v mono">{{.ID}}</span></div>
|
||||
<div class="row"><span class="k">Email</span><span class="v">{{.Email}}</span></div>
|
||||
<div class="row"><span class="k">Role</span><span class="v">{{.Role}}</span></div>
|
||||
<div class="row"><span class="k">Verified contributor</span><span class="v">{{if .Verified}}<span class="pill pill-yes">yes</span>{{else}}<span class="pill pill-no">no</span>{{end}}</span></div>
|
||||
<div class="row"><span class="k">Member since</span><span class="v mono">{{.CreatedAt}}</span></div>
|
||||
</section>
|
||||
<p class="hint">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.</p>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user