Open the admin console from serve --open and land login on the intended page
This commit is contained in:
@@ -130,7 +130,7 @@ func runServe(args []string) error {
|
||||
AutoMigrate: true,
|
||||
}
|
||||
if *open {
|
||||
openBrowser("http://" + *addr + "/ui")
|
||||
openBrowser("http://" + *addr + "/ui/admin")
|
||||
}
|
||||
|
||||
// Print the login once the server is about to start.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<p class="eyebrow">SciMesh</p>
|
||||
<h1>Sign in</h1>
|
||||
<form method="post" action="/ui/login">
|
||||
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
@@ -53,7 +55,7 @@ type tokenVerifier interface {
|
||||
}
|
||||
|
||||
func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")})
|
||||
s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error"), "Next": r.URL.Query().Get("next")})
|
||||
}
|
||||
|
||||
func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -85,7 +87,13 @@ func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, resp.Token)
|
||||
http.Redirect(w, r, "/ui", http.StatusSeeOther)
|
||||
// Land back where the user was headed (e.g. /ui/admin); never follow a
|
||||
// value that escapes the UI prefix — that would be an open redirect.
|
||||
next := strings.TrimSpace(r.FormValue("next"))
|
||||
if next == "" || !strings.HasPrefix(next, "/ui/") {
|
||||
next = "/ui"
|
||||
}
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUIRegister creates an account through the userservice, then sends the
|
||||
@@ -167,5 +175,15 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||
// Remember where the user was headed so a successful login lands back
|
||||
// there (e.g. /ui/admin) instead of the control room.
|
||||
next := r.URL.Path
|
||||
if !strings.HasPrefix(next, "/ui/") {
|
||||
next = ""
|
||||
}
|
||||
target := "/ui/login"
|
||||
if next != "" {
|
||||
target += "?next=" + url.QueryEscape(next)
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -173,3 +173,56 @@ func TestHandleUILogoutClearsCookie(t *testing.T) {
|
||||
t.Error("logout must clear the session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUILoginRedirectsToNext(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"token":"t"}`))
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
// A UI-scoped next is honoured: the admin lands back on the console.
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"p"}, "next": {"/ui/admin"}}))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/admin" {
|
||||
t.Errorf("got %d -> %q, want 303 -> /ui/admin", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// Anything outside the UI prefix must not become a redirect target.
|
||||
for _, next := range []string{"https://evil.example", "/", "//evil.example", "/api/jobs"} {
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"p"}, "next": {next}}))
|
||||
if loc := rec.Header().Get("Location"); loc != "/ui" {
|
||||
t.Errorf("next=%q landed on %q, want /ui (no open redirect)", next, loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectToLoginCarriesNext(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := newReq(http.MethodGet, "/ui/admin", nil)
|
||||
redirectToLogin(rec, req)
|
||||
if loc := rec.Header().Get("Location"); loc != "/ui/login?next=%2Fui%2Fadmin" {
|
||||
t.Errorf("location = %q, want /ui/login?next=%%2Fui%%2Fadmin", loc)
|
||||
}
|
||||
|
||||
// Paths outside the UI stay on the plain login.
|
||||
rec = httptest.NewRecorder()
|
||||
req = newReq(http.MethodGet, "/health", nil)
|
||||
redirectToLogin(rec, req)
|
||||
if loc := rec.Header().Get("Location"); loc != "/ui/login" {
|
||||
t.Errorf("location = %q, want /ui/login", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFormRendersNext(t *testing.T) {
|
||||
html := render(t, "login.html", map[string]any{"Next": "/ui/admin"})
|
||||
if !strings.Contains(html, `name="next" value="/ui/admin"`) {
|
||||
t.Error("login form must carry the next field")
|
||||
}
|
||||
html = render(t, "login.html", map[string]any{})
|
||||
if strings.Contains(html, `name="next"`) {
|
||||
t.Error("login form must not render next when absent")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user