panel-redesign: Sign in and Create administrator on the kit
Stage 2, step 1. Written by Sonnet, reviewed by Opus.
- templates/login.html, setup.html: written from the mockups as the
content of layout_signed_out - form.sp-form, kicker, h1, Bulma fields
with icons, the full-width primary button. Actions, field names,
autocomplete and autofocus are the old ones.
- view/signin.go: typed Login and Setup (Meta carries a title only, so
Engine.Render takes the signed-out layout); auth handlers pass them
instead of maps.
- login and setup are in kitPages, have fixtures, and are off
legacy_pages.txt.
States the mockups do not draw:
- a refused sign-in / setup is the kit's flash (notification is-danger
is-light) above the fields;
- login before any administrator exists shows the old sentence in a
form.sp-form that has no action and no control: the signed-out
stylesheet sizes and centres its column through "form" alone. A
workaround, listed for the owner.
Differences from the mockups, for the owner:
- the kicker is <p><span class="sp-kicker"> (the kit's partial is a
span; a page may not write the class), the mockup has <p
class="sp-kicker">: the block sits about 3px lower;
- login's kicker is the configured host name and is absent when there
is none; setup's is the mockup's fixed text;
- the footer is the layout's legal line (full copyright holder name);
- the user name field has autofocus, so it shows its focus ring.
Evidence
- docs/assets/panel-redesign/evidence/login.png, setup.png: 1440x900,
headless Edge, from the real templates served locally with their
fixtures - NOT from the container (no Docker on this machine).
- panel/audit.js on login, login with an error, login without an
administrator, setup, setup with an error, at 1440 and 1280:
118 texts checked, problems: [] ; no page is taller than 900px.
- go test -count=1 ./internal/web/...
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func renderSignedOut(t *testing.T, page string, data any) string {
|
||||
t.Helper()
|
||||
engine, err := New("9.9.9-test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Render(rec, http.StatusOK, page, data)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("render %s: status %d: %s", page, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
func TestLoginFormPostsToLoginWithItsFields(t *testing.T) {
|
||||
out := renderSignedOut(t, "login", NewLogin("mail.example.org", ""))
|
||||
for _, want := range []string{
|
||||
`action="/login"`, `method="post"`,
|
||||
`name="username"`, `autocomplete="username"`, `autofocus`,
|
||||
`name="password"`, `autocomplete="current-password"`,
|
||||
`mail.example.org`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("login page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "notification") {
|
||||
t.Error("an untouched login form shows an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginShowsAnErrorEscaped(t *testing.T) {
|
||||
out := renderSignedOut(t, "login", NewLogin("", `bad <script>alert(1)</script>`))
|
||||
if !strings.Contains(out, "notification is-danger is-light") {
|
||||
t.Error("the refusal is not shown as the error flash")
|
||||
}
|
||||
if strings.Contains(out, "<script>alert(1)") || !strings.Contains(out, "<script>alert(1)") {
|
||||
t.Errorf("the refusal is not escaped:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `class="sp-kicker"`) {
|
||||
t.Error("a kicker is shown although the host name is not known")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithoutAnAdministratorHasNoForm(t *testing.T) {
|
||||
out := renderSignedOut(t, "login", NewLoginSetupHint("mail.example.org"))
|
||||
if !strings.Contains(out, "No administrator has been created yet") {
|
||||
t.Error("the setup hint is missing")
|
||||
}
|
||||
// The element stays (the signed-out layout centres the column through it) but
|
||||
// nothing in it can be filled in or sent.
|
||||
for _, bad := range []string{`action=`, `<input`, `<button`, `name="password"`, `name="username"`} {
|
||||
if strings.Contains(out, bad) {
|
||||
t.Errorf("the hint page carries %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupFormPostsToItsOneTimeLink(t *testing.T) {
|
||||
out := renderSignedOut(t, "setup", NewSetup("abc123", ""))
|
||||
for _, want := range []string{
|
||||
`action="/setup/abc123"`, `method="post"`,
|
||||
`name="username"`, `autocomplete="username"`, `autofocus`,
|
||||
`name="password"`, `name="password_confirm"`, `autocomplete="new-password"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("setup page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "notification") {
|
||||
t.Error("an untouched setup form shows an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupShowsAnErrorEscaped(t *testing.T) {
|
||||
out := renderSignedOut(t, "setup", NewSetup("t", `Passwords <b>do not</b> match.`))
|
||||
if !strings.Contains(out, "notification is-danger is-light") {
|
||||
t.Error("the refusal is not shown as the error flash")
|
||||
}
|
||||
if strings.Contains(out, "<b>do not</b>") {
|
||||
t.Errorf("the refusal is not escaped:\n%s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user