From 029b26e6ae82b259349f2b92b4592791dba6a190 Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 3 Aug 2026 02:06:45 +0300 Subject: [PATCH] Explain the admin-required error and offer account switching on the login page --- coordinator/internal/transport/http/server.go | 1 + .../transport/http/templates/login.html | 5 +++- .../transport/http/templates/logout-form.html | 24 ++++++++++++++++ .../internal/transport/http/ui_admin.go | 7 ++++- .../transport/http/ui_admin_internal_test.go | 28 +++++++++++++++++-- .../internal/transport/http/ui_auth.go | 7 +++++ 6 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 coordinator/internal/transport/http/templates/logout-form.html diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index 2f2d51e..1bc6e95 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -158,6 +158,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { // Public auth pages — reachable without a session so a user can log in. ui.HandleFunc("GET /ui/login", s.handleUILoginForm) ui.HandleFunc("POST /ui/login", s.handleUILogin) + ui.HandleFunc("GET /ui/logout-form", s.handleUILogoutForm) ui.HandleFunc("GET /ui/register", s.handleUIRegisterForm) ui.HandleFunc("POST /ui/register", s.handleUIRegister) ui.HandleFunc("POST /ui/logout", s.handleUILogout) diff --git a/coordinator/internal/transport/http/templates/login.html b/coordinator/internal/transport/http/templates/login.html index 8e7d22f..e3dd549 100644 --- a/coordinator/internal/transport/http/templates/login.html +++ b/coordinator/internal/transport/http/templates/login.html @@ -20,7 +20,10 @@ - {{if .Error}}

{{.Error}}

{{end}} + {{if eq .Error "admin role required"}} +

The admin console is reserved for the cluster administrator.

+

You are signed in as a non-admin. Log out, then sign in with the admin account — its login is printed by coordinator serve on first start and stored in ~/.scimesh/admin.password.

+ {{else if .Error}}

{{.Error}}

{{end}}

No account? Register

diff --git a/coordinator/internal/transport/http/templates/logout-form.html b/coordinator/internal/transport/http/templates/logout-form.html new file mode 100644 index 0000000..49e81fc --- /dev/null +++ b/coordinator/internal/transport/http/templates/logout-form.html @@ -0,0 +1,24 @@ +{{define "logout-form.html"}} + + + + + + Sign out · SciMesh + + + +
+

SciMesh

+

Sign out

+

End the current session so you can sign in with a different account (for example the cluster administrator).

+
+ +
+

Changed your mind? Back to sign in

+
+ + +{{end}} diff --git a/coordinator/internal/transport/http/ui_admin.go b/coordinator/internal/transport/http/ui_admin.go index 0234bd1..47f3bad 100644 --- a/coordinator/internal/transport/http/ui_admin.go +++ b/coordinator/internal/transport/http/ui_admin.go @@ -33,7 +33,12 @@ var adminUserActions = map[string]bool{ func requireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if req, ok := authctx.From(r.Context()); !ok || !req.IsAdmin() { - http.Redirect(w, r, "/ui/login?error=admin+role+required", http.StatusSeeOther) + target := "/ui/login?error=admin+role+required" + // Keep the destination so a successful login lands straight back. + if strings.HasPrefix(r.URL.Path, "/ui/") { + target += "&next=" + url.QueryEscape(r.URL.Path) + } + http.Redirect(w, r, target, http.StatusSeeOther) return } next.ServeHTTP(w, r) diff --git a/coordinator/internal/transport/http/ui_admin_internal_test.go b/coordinator/internal/transport/http/ui_admin_internal_test.go index 355b802..19eadf9 100644 --- a/coordinator/internal/transport/http/ui_admin_internal_test.go +++ b/coordinator/internal/transport/http/ui_admin_internal_test.go @@ -29,15 +29,15 @@ func TestRequireAdminAllowsAdminOnly(t *testing.T) { t.Error("admin must reach the handler") } - // Plain user is redirected to the login with the reason. + // Plain user is redirected to the login with the reason and the destination. reached = false rec := httptest.NewRecorder() h.ServeHTTP(rec, adminReq(t, "user")) if reached { t.Error("non-admin must not reach the handler") } - if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/login?error=admin+role+required" { - t.Errorf("non-admin got %d -> %q, want 303 -> login with the admin-required error", rec.Code, rec.Header().Get("Location")) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/login?error=admin+role+required&next=%2Fui%2Fadmin" { + t.Errorf("non-admin got %d -> %q, want 303 -> login with the admin-required error and next", rec.Code, rec.Header().Get("Location")) } } @@ -99,3 +99,25 @@ func TestAdminUserActionRejectsBadID(t *testing.T) { t.Errorf("bad id redirect = %q, want an error", rec.Header().Get("Location")) } } + +func TestLoginPageExplainsAdminRequiredError(t *testing.T) { + html := render(t, "login.html", map[string]any{"Error": "admin role required"}) + if !strings.Contains(html, "/ui/logout-form") { + t.Error("the admin-required error must offer a logout path to switch accounts") + } + if !strings.Contains(html, "cluster administrator") { + t.Error("the admin-required error must name the admin account") + } + // Other errors keep the plain message, no logout teaser. + plain := render(t, "login.html", map[string]any{"Error": "invalid email or password"}) + if strings.Contains(plain, "/ui/logout-form") { + t.Error("plain login errors must not advertise logout") + } +} + +func TestLogoutFormRendersPostButton(t *testing.T) { + html := render(t, "logout-form.html", map[string]any{}) + if !strings.Contains(html, `action="/ui/logout"`) || !strings.Contains(html, "Log out") { + t.Error("logout form must POST /ui/logout") + } +} diff --git a/coordinator/internal/transport/http/ui_auth.go b/coordinator/internal/transport/http/ui_auth.go index 359dc75..405e0fd 100644 --- a/coordinator/internal/transport/http/ui_auth.go +++ b/coordinator/internal/transport/http/ui_auth.go @@ -58,6 +58,13 @@ func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) { s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error"), "Next": r.URL.Query().Get("next")}) } +// handleUILogoutForm renders a small confirm page for ending the current +// session. The actual logout stays a POST (/ui/logout); this page exists so a +// signed-in non-admin who hit an admin-only page can switch accounts. +func (s *Server) handleUILogoutForm(w http.ResponseWriter, r *http.Request) { + s.renderUI(w, "logout-form.html", map[string]any{}) +} + func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) { s.renderUI(w, "register.html", map[string]any{"Error": r.URL.Query().Get("error")}) }