From 73935ed11c5644f7a79b5b414cd81618fcc2f418 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 22:14:28 +0200 Subject: [PATCH] feat(02-04): signup templates (full page + HTMX fragment) with render tests - Create auth_form_errors.templ: FieldError and GeneralError primitives - Create auth_signup.templ: SignupPage (full) and SignupFormFragment (HTMX swap target) - Define SignupForm and SignupErrors types in templates/auth_forms.go - Add three smoke tests: renders form, renders errors, does not echo password --- backend/templates/auth_form_errors.templ | 19 +++++++ backend/templates/auth_forms.go | 17 ++++++ backend/templates/auth_signup.templ | 72 ++++++++++++++++++++++++ backend/templates/auth_signup_test.go | 65 +++++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 backend/templates/auth_form_errors.templ create mode 100644 backend/templates/auth_forms.go create mode 100644 backend/templates/auth_signup.templ create mode 100644 backend/templates/auth_signup_test.go diff --git a/backend/templates/auth_form_errors.templ b/backend/templates/auth_form_errors.templ new file mode 100644 index 0000000..f20b74b --- /dev/null +++ b/backend/templates/auth_form_errors.templ @@ -0,0 +1,19 @@ +package templates + +// FieldError renders a small red error message beneath a form field. +// Renders nothing when msg is empty so callers can unconditionally include it. +templ FieldError(msg string) { + if msg != "" { +

{ msg }

+ } +} + +// GeneralError renders a full-width error banner above the form. +// Renders nothing when msg is empty. +templ GeneralError(msg string) { + if msg != "" { +
+ { msg } +
+ } +} diff --git a/backend/templates/auth_forms.go b/backend/templates/auth_forms.go new file mode 100644 index 0000000..f3e6d83 --- /dev/null +++ b/backend/templates/auth_forms.go @@ -0,0 +1,17 @@ +package templates + +// SignupForm carries the submitted field values back to the template so +// the email field can be repopulated on validation failure. +// Password is intentionally never echoed back to the client (T-2-01, D-25). +type SignupForm struct { + Email string + Password string // held here only for length validation; never passed to templates +} + +// SignupErrors holds per-field and general error messages for the signup form. +// A field with an empty string means "no error for this field". +type SignupErrors struct { + Email string + Password string + General string +} diff --git a/backend/templates/auth_signup.templ b/backend/templates/auth_signup.templ new file mode 100644 index 0000000..facc389 --- /dev/null +++ b/backend/templates/auth_signup.templ @@ -0,0 +1,72 @@ +package templates + +import "backend/internal/web/ui" + +// SignupPage renders the full /signup page wrapped in the base Layout. +// It delegates the form section to SignupFormFragment so HTMX can swap just the +// form on validation errors without re-rendering the surrounding shell. +templ SignupPage(form SignupForm, errs SignupErrors) { + @Layout("Sign up") { +
+ @ui.Card(nil) { +
+

Create your account

+ @SignupFormFragment(form, errs) +
+ } +
+ } +} + +// SignupFormFragment is the bare form used for HTMX swaps. +// hx-post targets this component itself so the form can be replaced inline +// on validation failure (D-19, D-25). +// The outer id="signup-form" must match the hx-target on this element. +templ SignupFormFragment(form SignupForm, errs SignupErrors) { +
+ + @GeneralError(errs.General) +
+ + + @FieldError(errs.Email) +
+
+ + + @FieldError(errs.Password) +
+ @ui.Button(ui.ButtonProps{ + Label: "Create account", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + }) +
+} diff --git a/backend/templates/auth_signup_test.go b/backend/templates/auth_signup_test.go new file mode 100644 index 0000000..3afb332 --- /dev/null +++ b/backend/templates/auth_signup_test.go @@ -0,0 +1,65 @@ +package templates + +import ( + "bytes" + "context" + "strings" + "testing" +) + +// TestSignupPage_RendersForm verifies the full SignupPage output contains the +// expected form attributes and that email value round-trips correctly. +func TestSignupPage_RendersForm(t *testing.T) { + var buf bytes.Buffer + err := SignupPage(SignupForm{Email: "x@y.z"}, SignupErrors{}).Render(context.Background(), &buf) + if err != nil { + t.Fatalf("SignupPage.Render: %v", err) + } + body := buf.String() + + for _, want := range []string{ + `name="email"`, + `name="password"`, + `action="/signup"`, + `hx-post="/signup"`, + `value="x@y.z"`, + } { + if !strings.Contains(body, want) { + t.Errorf("SignupPage body missing %q", want) + } + } +} + +// TestSignupFormFragment_RendersErrors verifies that SignupFormFragment renders +// field-specific error messages and does NOT include a full tag (it is +// a fragment, not a complete page). +func TestSignupFormFragment_RendersErrors(t *testing.T) { + var buf bytes.Buffer + errs := SignupErrors{Password: "Password must be 12-128 characters"} + err := SignupFormFragment(SignupForm{}, errs).Render(context.Background(), &buf) + if err != nil { + t.Fatalf("SignupFormFragment.Render: %v", err) + } + body := buf.String() + + if !strings.Contains(body, "Password must be 12-128 characters") { + t.Errorf("fragment missing error message; body: %s", body) + } + if strings.Contains(body, " tag; got full page") + } +} + +// TestSignupPage_DoesNotEchoPassword verifies that the password value is never +// reflected back into any rendered HTML — even when form.Password is set +// (security requirement T-2-01, D-25). +func TestSignupPage_DoesNotEchoPassword(t *testing.T) { + var buf bytes.Buffer + err := SignupPage(SignupForm{Email: "a@b.com", Password: "hunter2hunter2"}, SignupErrors{}).Render(context.Background(), &buf) + if err != nil { + t.Fatalf("SignupPage.Render: %v", err) + } + if strings.Contains(buf.String(), "hunter2") { + t.Errorf("SignupPage must not echo back the password value") + } +}