Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
029b26e6ae |
@@ -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)
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<button class="button" type="submit">Sign in</button>
|
||||
</form>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
{{if eq .Error "admin role required"}}
|
||||
<p class="error">The admin console is reserved for the cluster administrator.</p>
|
||||
<p class="alt">You are signed in as a non-admin. <a href="/ui/logout-form">Log out</a>, then sign in with the admin account — its login is printed by <code>coordinator serve</code> on first start and stored in <code>~/.scimesh/admin.password</code>.</p>
|
||||
{{else if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<p class="alt">No account? <a href="/ui/register">Register</a></p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "logout-form.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign out · 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;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}a{color:#94bdff}.card{width:min(92vw,380px);border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:28px}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0 0 12px;color:#f4f8ff;font-size:1.5rem;letter-spacing:-.03em}p{margin:0 0 18px;color:#9fb3cf;font-size:.92rem}code{color:#cfe0ff}.button{display:block;width:100%;margin-top:4px;border:0;border-radius:10px;padding:12px 16px;background:#ff7d92;color:#230810;font:inherit;font-weight:850;cursor:pointer}.alt{margin:18px 0 0;color:#9fb3cf;font-size:.92rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<p class="eyebrow">SciMesh</p>
|
||||
<h1>Sign out</h1>
|
||||
<p>End the current session so you can sign in with a different account (for example the cluster administrator).</p>
|
||||
<form method="post" action="/ui/logout">
|
||||
<button class="button" type="submit">Log out</button>
|
||||
</form>
|
||||
<p class="alt">Changed your mind? <a href="/ui/login">Back to sign in</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user